diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index bf00631..80a3e6a 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -135,6 +135,7 @@ import { createProjectGoalRepos, createRepos, createSpaceForMember, + DuplicateSpaceNameError, deleteEmptySpaceForMember, deleteUnreferencedCredentialSecret, ensureMainConversation, @@ -783,15 +784,26 @@ export function createRouter(deps: RouterDeps) { const goal = input.goal?.trim() || (workspacePath ? await readProjectSummary(workspacePath) : null); if (goal) { - await deps.prisma.memoryDocument.create({ - data: { - spaceId: space.id, - userId: context.actor.userId, - scope: "user", - path: "PROJECT_GOAL.md", - content: goal, - }, + const existingGoal = await deps.prisma.memoryDocument.findFirst({ + where: { spaceId: space.id, scope: "user", path: "PROJECT_GOAL.md" }, + select: { id: true }, }); + if (existingGoal) { + await deps.prisma.memoryDocument.update({ + where: { id: existingGoal.id }, + data: { content: goal }, + }); + } else { + await deps.prisma.memoryDocument.create({ + data: { + spaceId: space.id, + userId: context.actor.userId, + scope: "user", + path: "PROJECT_GOAL.md", + content: goal, + }, + }); + } } // A project starts as a conversation with its own main agent. await ensureMainConversation(deps.prisma, context.actor.userId, space.id); @@ -873,7 +885,7 @@ export function createRouter(deps: RouterDeps) { throw new ORPCError("NOT_FOUND", { message: error.message }); } if (error instanceof IsolationError) throw new ORPCError("FORBIDDEN"); - if (error instanceof InvalidSpaceNameError) { + if (error instanceof InvalidSpaceNameError || error instanceof DuplicateSpaceNameError) { throw new ORPCError("BAD_REQUEST", { message: error.message }); } throw error; @@ -5623,13 +5635,13 @@ async function spaceNavigationDto( // The user's own main agent of a project is part of the project, not content to clear. deps.prisma.bot.findMany({ where: { spaceId: { in: spaceIds }, NOT: ownProjectMain }, - select: { spaceId: true }, - distinct: ["spaceId"], + select: { spaceId: true, userId: true }, + distinct: ["spaceId", "userId"], }), deps.prisma.chatGroup.findMany({ where: { spaceId: { in: spaceIds }, NOT: ownProjectMain }, - select: { spaceId: true }, - distinct: ["spaceId"], + select: { spaceId: true, userId: true }, + distinct: ["spaceId", "userId"], }), deps.prisma.memoryDocument.findMany({ where: { spaceId: { in: spaceIds }, scope: "user", path: "PROJECT_GOAL.md" }, @@ -5640,6 +5652,11 @@ async function spaceNavigationDto( ...contentBots.map((row) => row.spaceId), ...contentGroups.map((row) => row.spaceId), ]); + const spacesBlockedByOtherMember = new Set( + [...contentBots, ...contentGroups] + .filter((row) => row.userId !== actor.userId) + .map((row) => row.spaceId), + ); const goalBySpaceId = new Map(goalDocuments.map((doc) => [doc.spaceId, doc.content])); const currentMembership = memberships.find((membership) => membership.spaceId === actor.spaceId); if (!currentMembership) throw new IsolationError(); @@ -5676,7 +5693,7 @@ async function spaceNavigationDto( membership.role === "owner" && !membership.space.isDefault && memberships.length > 1 && - !spacesWithContent.has(membership.spaceId) && + !spacesBlockedByOtherMember.has(membership.spaceId) && (membership.space.deletingAt === null || membership.space.deletingAt < staleClaimBefore), canEdit: membership.role === "owner", // A linked folder is on the owner's host; other members see neither its path nor remote. diff --git a/apps/mobile/app/group-settings.tsx b/apps/mobile/app/group-settings.tsx index bd667a6..e2c7e43 100644 --- a/apps/mobile/app/group-settings.tsx +++ b/apps/mobile/app/group-settings.tsx @@ -3,8 +3,15 @@ import { Stack, useLocalSearchParams, useRouter } from "expo-router"; import { useEffect, useState } from "react"; import { Alert, Pressable, ScrollView, Text, TextInput } from "react-native"; import { BotMemberPicker } from "../components/bot-member-picker"; -import { type MobileBot, type MobileGroup, rpc } from "../lib/api"; +import { + type MobileBot, + type MobileGroup, + type MobileSpace, + type MobileSpaceNavigation, + rpc, +} from "../lib/api"; import { useI18n } from "../lib/i18n"; +import { canDeleteInboxSpace, removeInboxSpace } from "../lib/inbox-spaces"; import { useMobileTokens } from "../lib/native"; export default function GroupSettingsScreen() { @@ -13,12 +20,16 @@ export default function GroupSettingsScreen() { const router = useRouter(); const { groupId } = useLocalSearchParams<{ groupId: string }>(); const [group, setGroup] = useState(null); + const [projectSpace, setProjectSpace] = useState(null); const [bots, setBots] = useState([]); const [name, setName] = useState(""); const [selected, setSelected] = useState([]); const [error, setError] = useState(null); const [pending, setPending] = useState(false); + const isMainConversation = group?.kind === "main"; + const isProjectMain = isMainConversation && projectSpace !== null && !projectSpace.isDefault; + useEffect(() => { if (!groupId) return; void Promise.all([ @@ -26,11 +37,19 @@ export default function GroupSettingsScreen() { (groups) => groups.find((row) => row.id === groupId) ?? null, ), rpc("bots/list"), + rpc("spaces/list").catch(() => null), ]) - .then(([nextGroup, nextBots]) => { + .then(([nextGroup, nextBots, navigation]) => { if (!nextGroup) throw new Error(t("Group not found")); setGroup(nextGroup); - setName(nextGroup.name); + const space = + navigation?.spaces.find( + (item) => item.id === nextGroup.spaceId || item.id === navigation.current.id, + ) ?? null; + setProjectSpace(space); + setName( + nextGroup.kind === "main" && space && !space.isDefault ? space.name : nextGroup.name, + ); setSelected(nextGroup.members.map((member) => member.botId)); setBots(nextBots.filter((bot) => !bot.archivedAt)); }) @@ -42,10 +61,20 @@ export default function GroupSettingsScreen() { setPending(true); setError(null); try { + const trimmed = name.trim(); + if (isProjectMain && projectSpace) { + if (trimmed && trimmed !== projectSpace.name) { + await rpc("spaces/update", { spaceId: projectSpace.id, name: trimmed }); + } + router.back(); + return; + } const input: { groupId: string; name?: string; botIds?: string[] } = { groupId }; - if (name.trim() !== group.name) input.name = name.trim(); - const memberIds = group.members.map((member) => member.botId).join(","); - if (selected.join(",") !== memberIds) input.botIds = selected; + if (trimmed !== group.name) input.name = trimmed; + if (!isMainConversation) { + const memberIds = group.members.map((member) => member.botId).join(","); + if (selected.join(",") !== memberIds) input.botIds = selected; + } if (input.name || input.botIds) await rpc("groups/update", input); router.back(); } catch (err) { @@ -57,6 +86,29 @@ export default function GroupSettingsScreen() { function remove() { if (!groupId || !group) return; + if (isProjectMain && projectSpace) { + Alert.alert( + t("Delete {name}?", { name: projectSpace.name }), + t("This removes the empty space for everyone."), + [ + { text: t("Cancel"), style: "cancel" }, + { + text: t("Delete"), + style: "destructive", + onPress: () => + void removeInboxSpace(projectSpace.id, async () => {}) + .then(() => router.replace("/")) + .catch((err) => + Alert.alert( + t("Could not delete space"), + err instanceof Error ? err.message : t("Try again."), + ), + ), + }, + ], + ); + return; + } Alert.alert(group.name, t("Delete this group? Bots and their solo threads are kept."), [ { text: t("Cancel"), style: "cancel" }, { @@ -75,6 +127,12 @@ export default function GroupSettingsScreen() { ]); } + const saveDisabled = + !name.trim() || + pending || + (!isMainConversation && + (selected.length < GROUP_MEMBER_MIN || selected.length > GROUP_MEMBER_MAX)); + return ( <> @@ -97,34 +155,27 @@ export default function GroupSettingsScreen() { fontSize: 16, }} /> - - {t("Members")} - - + {!isMainConversation ? ( + <> + + {t("Members")} + + + + ) : null} {error ? {error} : null} void save()} - disabled={ - !name.trim() || - selected.length < GROUP_MEMBER_MIN || - selected.length > GROUP_MEMBER_MAX || - pending - } + disabled={saveDisabled} style={{ marginTop: 24, backgroundColor: tokens.primary, - opacity: - !name.trim() || - selected.length < GROUP_MEMBER_MIN || - selected.length > GROUP_MEMBER_MAX || - pending - ? 0.5 - : 1, + opacity: saveDisabled ? 0.5 : 1, borderRadius: 11, padding: 14, alignItems: "center", @@ -134,19 +185,35 @@ export default function GroupSettingsScreen() { {pending ? t("Saving…") : t("Save")} - - {t("Delete group")} - + {isProjectMain && projectSpace && canDeleteInboxSpace(projectSpace) ? ( + + {t("Delete space")} + + ) : !isMainConversation ? ( + + {t("Delete group")} + + ) : null} ); diff --git a/apps/mobile/app/index.tsx b/apps/mobile/app/index.tsx index 361d4b4..220c6d5 100644 --- a/apps/mobile/app/index.tsx +++ b/apps/mobile/app/index.tsx @@ -597,7 +597,21 @@ export default function Home() { }} disabled={spaceBusy} onPress={() => { - if (item.space) void chooseInboxSpace(item.space.id); + if (!item.space) return; + const targetSpace = item.space; + const main = !targetSpace.isDefault + ? targetSpace.groups.find((group) => group.kind === "main") + : undefined; + if (main) { + void openMobileSpace(targetSpace.id, () => + router.push({ + pathname: "/group-thread", + params: { groupId: main.id, name: targetSpace.name }, + }), + ); + return; + } + void chooseInboxSpace(targetSpace.id); }} style={({ pressed }) => [styles.spaceSelect, pressed && styles.rowPressed]} > diff --git a/apps/mobile/lib/inbox-spaces.test.ts b/apps/mobile/lib/inbox-spaces.test.ts index 42e9472..e0cb18a 100644 --- a/apps/mobile/lib/inbox-spaces.test.ts +++ b/apps/mobile/lib/inbox-spaces.test.ts @@ -147,12 +147,18 @@ describe("spaceInboxItems", () => { expect(canDeleteInboxSpace(space({ canDelete }))).toBe(false); }); - it("offers deletion for an explicitly authorized empty non-default space", () => { + it("offers deletion for an explicitly authorized non-default space", () => { expect(canDeleteInboxSpace(space())).toBe(true); + expect(canDeleteInboxSpace(space({ hasContent: true, canDelete: true }))).toBe(true); }); - it("hides deletion while a space still has content", () => { - expect(canDeleteInboxSpace(space({ hasContent: true }))).toBe(false); + it("deduplicates same-named projects into a single project entry", () => { + const first = space({ id: "space-work-1", name: "Work", hasContent: false }); + const second = space({ id: "space-work-2", name: " work ", hasContent: true, bots: [bot] }); + const items = spaceInboxItems([first, second]); + const headings = items.filter((item) => item.type === "heading" && item.space); + expect(headings).toHaveLength(1); + expect(headings[0]).toMatchObject({ key: "space-work-2", title: " work " }); }); }); diff --git a/apps/mobile/lib/inbox-spaces.ts b/apps/mobile/lib/inbox-spaces.ts index 532c081..dbb44e8 100644 --- a/apps/mobile/lib/inbox-spaces.ts +++ b/apps/mobile/lib/inbox-spaces.ts @@ -24,22 +24,47 @@ export type InboxSpaceItem = | { type: "heading"; key: string; title: string; space?: InboxSpace }; export function canDeleteInboxSpace( - space: Pick, + space: Pick & Partial>, ) { - return !space.isDefault && !space.hasContent && space.canDelete === true; + return !space.isDefault && space.canDelete === true; } export function spaceInboxItems(spaces: InboxSpace[]): InboxSpaceItem[] { - return spaces.flatMap((space): InboxSpaceItem[] => { + const seenProjectNames = new Set(); + const canonicalProjectIds = new Set(); + const orderedProjects = [...spaces] + .filter((space) => !space.isDefault) + .sort((left, right) => Number(Boolean(right.hasContent)) - Number(Boolean(left.hasContent))); + for (const space of orderedProjects) { + const normalized = space.name.trim().toLowerCase(); + if (normalized && seenProjectNames.has(normalized)) continue; + if (normalized) seenProjectNames.add(normalized); + canonicalProjectIds.add(space.id); + } + + const visibleSpaces = spaces.filter( + (space) => space.isDefault || canonicalProjectIds.has(space.id), + ); + + return visibleSpaces.flatMap((space): InboxSpaceItem[] => { + const normalizedSpaceName = space.name.trim().toLowerCase(); const chats = [ // A main bot is reached through its space's main conversation, not listed again. ...space.bots .filter((chat) => chat.kind !== "main") .map((chat) => ({ type: "bot" as const, bot: chat, ...chat })), - ...space.groups.map((chat) => ({ type: "group" as const, group: chat, ...chat })), + ...space.groups + .filter((chat) => { + const normalizedGroupName = chat.name.trim().toLowerCase(); + if (!space.isDefault && normalizedGroupName === normalizedSpaceName) return false; + if (space.isDefault && chat.kind !== "main" && seenProjectNames.has(normalizedGroupName)) + return false; + return true; + }) + .map((chat) => ({ type: "group" as const, group: chat, ...chat })), ]; const items: InboxSpaceItem[] = []; - if (spaces.length > 1 || !space.isDefault || chats.length === 0) { + if (visibleSpaces.length > 1 || !space.isDefault || chats.length === 0) { items.push({ type: "heading", key: space.id, title: space.name, space }); } for (const group of groupBotsForSidebar(chats, space.botSections)) { diff --git a/apps/web/e2e/first-run-onboarding-ux.spec.ts b/apps/web/e2e/first-run-onboarding-ux.spec.ts index 73fea3b..d368d08 100644 --- a/apps/web/e2e/first-run-onboarding-ux.spec.ts +++ b/apps/web/e2e/first-run-onboarding-ux.spec.ts @@ -67,7 +67,7 @@ test("main conversation does not show a group subtitle for one Oo", async ({ pag await signup(page, `main-1-1-${stamp}@rakazo.test`, "password12", "Solo"); await completeOnboarding(page, undefined, { mainConversation: true }); await expect(page.getByTestId("bot-settings-trigger")).not.toContainText("Gruppenunterhaltung"); - await expect(page.getByTestId("oo-group-stage")).not.toContainText("Gruppenunterhaltung"); + await expect(page.getByTestId("oo-group-stage")).toHaveCount(0); }); test.describe("mobile shell", () => { diff --git a/apps/web/e2e/main-conversation.spec.ts b/apps/web/e2e/main-conversation.spec.ts index 1f68767..23ac2e8 100644 --- a/apps/web/e2e/main-conversation.spec.ts +++ b/apps/web/e2e/main-conversation.spec.ts @@ -26,7 +26,8 @@ test("onboarding lands in the main conversation, its lead answers, and addressed await expect(mainEntry).toBeVisible(); // The main bot is reached through the main conversation, not listed again. await expect(sidebar.locator(`[data-roster-bot-id="${chief.id}"]`)).toHaveCount(0); - await expect(page.getByTestId("oo-group-stage")).toBeVisible(); + await expect(page.getByTestId("oo-group-stage")).toHaveCount(0); + await expect(page.getByTestId("bot-settings-trigger")).toBeVisible(); await captureScreenshot(page, testInfo, "main-conversation-landing"); const snapshot = () => rpc(page, "threads/get", { groupId }); diff --git a/apps/web/src/components/voice/VoiceLiveText.tsx b/apps/web/src/components/voice/VoiceLiveText.tsx index 1c0f262..bf69a03 100644 --- a/apps/web/src/components/voice/VoiceLiveText.tsx +++ b/apps/web/src/components/voice/VoiceLiveText.tsx @@ -17,7 +17,7 @@ export function VoiceLiveText({ text, reducedMotion }: { text: string; reducedMo return (

{captionWords(text).map((word, index) => ( diff --git a/apps/web/src/components/voice/VoiceStage.tsx b/apps/web/src/components/voice/VoiceStage.tsx index 31250c9..f099fb0 100644 --- a/apps/web/src/components/voice/VoiceStage.tsx +++ b/apps/web/src/components/voice/VoiceStage.tsx @@ -90,21 +90,52 @@ export function VoiceStage({ }, []); return ( -

{botName}

+
+
+ {transcriptLines.length ? ( +
    + {transcriptLines.map((line, index) => ( +
  • + + {line.role === "user" ? t`You` : (line.speakerName ?? botName)} + + + {line.text} + +
  • + ))} +
+ ) : ( +

+ Your conversation will appear here. +

+ )} +
+
+ {awarenessLabel && awarenessEvent ? ( -
+
-
-
+
+
- - {error ? ( -

- {error} -

- ) : null} +
+ + {error ? ( +

+ {error} +

+ ) : null} +
-
+
{phaseLabel} @@ -187,39 +220,6 @@ export function VoiceStage({
- -
-
- {transcriptLines.length ? ( -
    - {transcriptLines.map((line, index) => ( -
  • - - {line.role === "user" ? t`You` : (line.speakerName ?? botName)} - - - {line.text} - -
  • - ))} -
- ) : ( -

- Your conversation will appear here. -

- )} -
-
-
+ ); } diff --git a/apps/web/src/lib/dictation.ts b/apps/web/src/lib/dictation.ts index f66150a..38c92a6 100644 --- a/apps/web/src/lib/dictation.ts +++ b/apps/web/src/lib/dictation.ts @@ -9,6 +9,37 @@ import { StreamingVad, } from "@rakazo/core"; import { selectedSpaceId, withSpaceHeaders } from "./rpc.js"; +import { readUiLocale } from "./ui-locale.js"; + +const SPEECH_LOCALE_TAGS: Record = { + de: "de-DE", + en: "en-US", + es: "es-ES", + fr: "fr-FR", + hi: "hi-IN", + it: "it-IT", + ja: "ja-JP", + ko: "ko-KR", + "pt-BR": "pt-BR", + ru: "ru-RU", + tr: "tr-TR", + "zh-CN": "zh-CN", +}; + +export function speechRecognitionLang(): string { + const uiLocale = readUiLocale(); + const mapped = uiLocale ? SPEECH_LOCALE_TAGS[uiLocale] : undefined; + if (uiLocale && mapped) { + // Prefer browser's regional variant when it matches the UI language (e.g. de-AT / de-CH) + const navLang = typeof navigator !== "undefined" ? navigator.language : ""; + const primary = uiLocale.split("-")[0]?.toLowerCase() ?? ""; + if (navLang?.toLowerCase().startsWith(`${primary}-`)) { + return navLang; + } + return mapped; + } + return (typeof navigator !== "undefined" && navigator.language) || "de-DE"; +} export type DictationMode = "hold" | "endpoint"; @@ -205,7 +236,7 @@ export class Dictation { const rec = new Ctor(); rec.continuous = true; rec.interimResults = true; - rec.lang = navigator.language || "en-US"; + rec.lang = speechRecognitionLang(); rec.onresult = (event) => { if (this.token !== mine) return; let transcript = ""; diff --git a/apps/web/src/lib/tts.ts b/apps/web/src/lib/tts.ts index 6ece0f1..3848fa6 100644 --- a/apps/web/src/lib/tts.ts +++ b/apps/web/src/lib/tts.ts @@ -1,5 +1,6 @@ import { ClauseSegmenter, readBoundedResponseBytes, speakable } from "@rakazo/core"; import { rpc, selectedSpaceId, withSpaceHeaders } from "./rpc.js"; +import { readUiLocale } from "./ui-locale.js"; export type SpeechStatus = "idle" | "preparing" | "speaking"; @@ -257,15 +258,57 @@ export class Speaker { window.speechSynthesis.cancel(); const utterance = new SpeechSynthesisUtterance(text); const voices = window.speechSynthesis.getVoices?.() ?? []; - const isGerman = /[äöüß]|(?:und|der|die|das|ist|nicht|für|mit|den|dem|des)/i.test(text); - const langPrefix = isGerman ? "de" : "en"; + const uiLocale = readUiLocale(); + const voiceId = opts.voiceId ?? ""; + const isGermanVoiceId = /^d[fm]_/i.test(voiceId); + const isGermanText = + /[äöüß]|(?:\b(?:und|der|die|das|ist|nicht|für|mit|den|dem|des|ein|eine|ich|wir|bitte)\b)/i.test( + text, + ); + const isGerman = isGermanVoiceId || uiLocale === "de" || isGermanText; + const langPrefix = isGerman ? "de" : uiLocale.split("-")[0] || "en"; + utterance.lang = isGerman ? "de-DE" : langPrefix === "en" ? "en-US" : uiLocale; + + const langVoices = voices.filter((v) => + v.lang.toLowerCase().replace(/_/g, "-").startsWith(langPrefix), + ); + const wantsMale = /^dm_/i.test(voiceId); + const wantsFemale = /^df_/i.test(voiceId); + const maleHint = /yannick|martin|markus|stefan|viktor|hans|daniel|thomas|male/i; + const femaleHint = /anna|petra|helena|marlene|vicki|katja|siri|eloquence|female/i; + const matchedVoice = - voices.find((v) => v.lang.startsWith(langPrefix) && !v.localService) || - voices.find((v) => v.lang.startsWith(langPrefix)) || + (wantsMale && + (langVoices.find((v) => !v.localService && maleHint.test(v.name)) || + langVoices.find((v) => maleHint.test(v.name)))) || + (wantsFemale && + (langVoices.find((v) => !v.localService && femaleHint.test(v.name)) || + langVoices.find((v) => femaleHint.test(v.name)))) || + langVoices.find((v) => !v.localService) || + langVoices[0] || voices[0]; if (matchedVoice) utterance.voice = matchedVoice; - utterance.rate = 1.05; - utterance.pitch = 1.0; + + // Distinct natural voice profiles for the 5 German presets when rendered via system TTS + if (voiceId === "dm_karlsson") { + utterance.rate = 0.98; + utterance.pitch = 0.85; + } else if (voiceId === "dm_thorsten") { + utterance.rate = 1.03; + utterance.pitch = 0.95; + } else if (voiceId === "df_ramona") { + utterance.rate = 0.97; + utterance.pitch = 1.0; + } else if (voiceId === "df_eva") { + utterance.rate = 1.02; + utterance.pitch = 1.04; + } else if (voiceId === "df_kerstin") { + utterance.rate = 1.05; + utterance.pitch = 1.08; + } else { + utterance.rate = 1.04; + utterance.pitch = 1.0; + } this.set({ status: "speaking", diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index df30c10..627d0dc 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -97,7 +97,7 @@ import { InAppBrowser } from "../components/browser/InAppBrowser"; import { ComputerUpdateProgress } from "../components/ComputerUpdateProgress"; import { DualSurfaceDivider } from "../components/DualSurfaceDivider"; import { MainInbox } from "../components/MainInbox"; -import { OoGroupStage, OoHeaderStatus, OoStage } from "../components/stage/OoStage"; +import { OoHeaderStatus } from "../components/stage/OoStage"; import { useDualSurfaceLayout } from "../hooks/useDualSurfaceLayout"; import { useSidebarResize } from "../hooks/useSidebarResize"; import { useWideLayout } from "../hooks/useWideLayout"; @@ -394,18 +394,6 @@ export function ShellPage() { const [screenHabit, setScreenHabit] = useState(0); const wideLayout = useWideLayout(); - const handleSelectLayoutMode = useCallback( - (mode: DualSurfaceLayoutMode) => { - dualSurface.setLayoutMode(mode); - if (mode === "stream") { - setPanel(null); - } else if (mode === "split" || mode === "canvas") { - setPanel((current) => current ?? "computer"); - } - }, - [dualSurface], - ); - const prevPanelRef = useRef(panel); useEffect(() => { if (!prevPanelRef.current && panel && dualSurface.layoutMode === "stream") { @@ -679,11 +667,13 @@ export function ShellPage() { activeGroup?.leadBotId ?? activeGroup?.members?.[0]?.botId ?? activeSpace.bots?.find((b) => b.kind === "main")?.id; - return ( + const found = (leadId ? bots.find((b) => b.id === leadId) : undefined) ?? bots.find((b) => b.kind === "main" && b.spaceId === activeSpace.id) ?? - bots.find((b) => b.spaceId === activeSpace.id) - ); + bots.find((b) => b.spaceId === activeSpace.id); + if (!found) return undefined; + const isLegacyName = found.name === "Chief" || found.name === "Projektkoordinator"; + return isLegacyName ? { ...found, name: "Projektmanager" } : found; }, [ inProject, activeSpace, @@ -693,16 +683,35 @@ export function ShellPage() { bots, ]); + const handleSelectLayoutMode = useCallback( + (mode: DualSurfaceLayoutMode) => { + dualSurface.setLayoutMode(mode); + if (mode === "stream") { + setPanel(null); + } else if (mode === "split" || mode === "canvas") { + setPanel((current) => current ?? (inProject ? null : "computer")); + } + }, + [dualSurface, inProject], + ); + + const autoOpenedProjectSpaceRef = useRef(null); useEffect(() => { - if (inProject && !dualSurface.isMobile && dualSurface.layoutMode === "stream") { + const projectSpaceId = inProject ? (activeSpace?.id ?? "__project__") : null; + if (!projectSpaceId) { + autoOpenedProjectSpaceRef.current = null; + return; + } + if (autoOpenedProjectSpaceRef.current === projectSpaceId) return; + autoOpenedProjectSpaceRef.current = projectSpaceId; + if (!dualSurface.isMobile && dualSurface.layoutMode === "stream") { dualSurface.setLayoutMode("split"); } - }, [inProject, dualSurface]); + }, [inProject, activeSpace?.id, dualSurface]); const activeProjectSection = activeGroup?.sectionId ? botSections.find((section) => section.id === activeGroup.sectionId) : undefined; - const showProjectTimeline = - inGroup && activeNavPillar !== "heute" && !projectSessionKey && Boolean(activeGroup?.sectionId); + const showProjectTimeline = false; const activePendingAttachments = useMemo( () => attachmentsForThread(pendingAttachments, inGroup ? groupId : active?.id), [active?.id, groupId, inGroup, pendingAttachments], @@ -1953,9 +1962,57 @@ export function ShellPage() { status: member.status, })); }, [activeSnapshot, enrichStageMember, inGroup, resolveTranscriptBot, workingRuns]); + const projectGroupMembers: GroupAvatarMember[] = useMemo(() => { + const byId = new Map(); + const rawMembers = activeGroup?.members ?? activeSnapshot?.members ?? []; + for (const member of rawMembers) { + const bot = resolveSpeakerBot(member.botId); + const name = + member.botId === coordinatorBot?.id ? coordinatorBot.name : (member.name ?? bot?.name); + byId.set(member.botId, { + botId: member.botId, + name, + color: member.color || bot?.color || FALLBACK_BOT_COLOR, + status: member.status ?? bot?.status, + }); + } + if (inProject && activeSpace) { + for (const spaceBot of activeSpace.bots ?? []) { + if (!byId.has(spaceBot.id)) { + const name = spaceBot.id === coordinatorBot?.id ? coordinatorBot.name : spaceBot.name; + byId.set(spaceBot.id, { + botId: spaceBot.id, + name, + color: spaceBot.color || FALLBACK_BOT_COLOR, + status: spaceBot.status, + }); + } + } + for (const bot of bots) { + if (bot.spaceId === activeSpace.id && !byId.has(bot.id)) { + const name = bot.id === coordinatorBot?.id ? coordinatorBot.name : bot.name; + byId.set(bot.id, { + botId: bot.id, + name, + color: bot.color || FALLBACK_BOT_COLOR, + status: bot.status, + }); + } + } + } + return [...byId.values()]; + }, [ + activeGroup?.members, + activeSnapshot?.members, + activeSpace, + bots, + coordinatorBot, + inProject, + resolveSpeakerBot, + ]); - const encounterMode = Boolean((active || activeGroup) && !showProjectTimeline && !callOpen); - const voiceEncounterActive = encounterMode || callOpen || voiceEntry; + const encounterMode = false; + const voiceEncounterActive = callOpen || voiceEntry; const voiceCallBot = useMemo(() => { if (active) return active; if (!inGroup || !activeSnapshot) return undefined; @@ -1983,14 +2040,15 @@ export function ShellPage() { useEffect(() => { if (!voiceEntry || showProjectTimeline || callOpen) return; if (!voiceCallBot) return; - if (voiceStatus?.ready) { - voiceSetupPrompted.current = false; - setCallOpen(true); - return; + voiceSetupPrompted.current = false; + setCallOpen(true); + if (!voiceStatus?.ready) { + void rpc.voice + .connect({ provider: "kokoro", apiKey: "local-kokoro" }) + .then(() => rpc.voice.status()) + .then(setVoiceStatus) + .catch(() => undefined); } - if (voiceSetupPrompted.current) return; - voiceSetupPrompted.current = true; - openSettings("voice"); }, [voiceEntry, showProjectTimeline, callOpen, voiceCallBot, voiceStatus?.ready]); const heuteActionCount = useHeuteActionCount(groups, botsRef); @@ -2005,9 +2063,15 @@ export function ShellPage() { }); const resolveTranscriptMemberName = useCallback( - (botId: string | undefined) => - memberName(transcriptMembers, botId) ?? (botId ? knownBots.get(botId)?.name : undefined), - [knownBots, transcriptMembers], + (botId: string | undefined) => { + if (botId && coordinatorBot && botId === coordinatorBot.id) { + return coordinatorBot.name; + } + return ( + memberName(transcriptMembers, botId) ?? (botId ? knownBots.get(botId)?.name : undefined) + ); + }, + [coordinatorBot, knownBots, transcriptMembers], ); const replyTargetName = activeReplyTarget ? activeReplyTarget.role === "user" @@ -2124,11 +2188,14 @@ export function ShellPage() { [groupId, navigate, searchParams, setSearchParams], ); const startVoiceEncounter = useCallback(() => { - if (voiceStatus?.ready) { - setCallOpen(true); - return; + setCallOpen(true); + if (!voiceStatus?.ready) { + void rpc.voice + .connect({ provider: "kokoro", apiKey: "local-kokoro" }) + .then(() => rpc.voice.status()) + .then(setVoiceStatus) + .catch(() => undefined); } - openSettings("voice"); }, [voiceStatus?.ready]); const closeProjectSession = useCallback(() => { @@ -3026,24 +3093,27 @@ export function ShellPage() { width: mobileSidebarOpen ? undefined : botsSidebarCollapsed ? 0 : leftSidebarWidth, maxWidth: mobileSidebarOpen ? 316 : undefined, }} - className={`absolute inset-y-0 start-0 z-40 flex w-[calc(100%-48px)] shrink-0 flex-col md:rounded-2xl md:border md:border-sidebar-border md:shadow-2xl bg-sidebar md:static md:z-auto md:translate-x-0 ${ + className={`absolute inset-y-0 start-0 z-40 flex w-[calc(100%-48px)] shrink-0 flex-col md:rounded-2xl md:border md:border-sidebar-border md:shadow-2xl bg-sidebar md:relative md:z-auto md:translate-x-0 ${ mobileSidebarOpen ? "translate-x-0" : "-translate-x-full rtl:translate-x-full" } ${ botsSidebarCollapsed - ? "md:w-0 md:max-w-0 md:overflow-hidden md:border-e-0 md:opacity-0 md:pointer-events-none" + ? "md:w-0 md:max-w-0 md:overflow-hidden md:border-0 md:-me-3 md:opacity-0 md:pointer-events-none" : "" } ${!isResizingLeft ? "transition-[transform,width,opacity] duration-300 ease-[cubic-bezier(0.16,1,0.3,1)]" : ""}`} > -
+ {!botsSidebarCollapsed ? ( +
+ ) : null} setMobileSidebarOpen(false)} + onCollapseSidebar={() => setBotsSidebarCollapsedPref(true)} bots={bots} onCreateBot={() => setPanel("create")} onOpenBot={(id) => navigate(`/app/${id}`)} @@ -3315,6 +3385,15 @@ export function ShellPage() { name={projectSpace.name} icon={projectSpace.icon} color={projectSpace.color} + members={(projectSpace.bots ?? []).map((b) => ({ + botId: b.id, + name: + b.name === "Chief" || b.name === "Projektkoordinator" + ? "Projektmanager" + : b.name, + color: b.color || FALLBACK_BOT_COLOR, + status: b.status, + }))} hasMenu={ projectSpace.canEdit === true || projectSpace.canDelete === true } @@ -3469,8 +3548,9 @@ export function ShellPage() { data-testid="bots-sidebar-edge" aria-label={botsSidebarCollapsed ? t`Show bots` : t`Hide bots`} aria-pressed={!botsSidebarCollapsed} + style={botsSidebarCollapsed ? undefined : { insetInlineStart: `${leftSidebarWidth + 8}px` }} className={`absolute inset-y-0 z-50 hidden w-2 cursor-ew-resize touch-none border-0 bg-transparent p-0 md:block ${ - botsSidebarCollapsed ? "start-0" : "start-[308px]" + botsSidebarCollapsed ? "start-0" : "" }`} onPointerDown={onBotsSidebarEdgePointerDown} onPointerMove={onBotsSidebarEdgePointerMove} @@ -3545,7 +3625,7 @@ export function ShellPage() { : showCanvasOnDesktop; const dividerElement = - !dualSurface.isMobile && dualSurface.layoutMode === "split" ? ( + !dualSurface.isMobile && showCanvasOnDesktop && dualSurface.layoutMode === "split" ? ( - {(!inGroup && active) || - (inGroup && activeGroup?.kind === "main" && coordinatorBot) ? ( + {inGroup && + (inProject || + activeGroup?.kind !== "main" || + projectGroupMembers.length > 1) ? ( + + ) : (!inGroup && active) || + (inGroup && activeGroup?.kind === "main" && coordinatorBot) ? ( ) : inGroup ? ( - + ) : null}
- - {inGroup && activeGroup?.kind === "main" - ? coordinatorBot?.name === "Chief" - ? "Projektkoordinator" - : (coordinatorBot?.name ?? - (activeGroup.name === "Chief" - ? "Projektkoordinator" - : activeGroup.name)) - : inGroup - ? (activeGroup?.name ?? activeSnapshot?.groupName ?? t`Group`) - : (active?.name ?? t`Choose a conversation`)} - - {(!inGroup && active) || - (inGroup && activeGroup?.kind === "main" && coordinatorBot) ? ( +
+ + {inGroup && activeGroup?.kind === "main" + ? inProject + ? (activeSpace?.name ?? + coordinatorBot?.name ?? + (activeGroup.name === "Chief" || + activeGroup.name === "Projektkoordinator" + ? "Projektmanager" + : activeGroup.name)) + : (coordinatorBot?.name ?? activeGroup.name) + : inGroup + ? (activeGroup?.name ?? activeSnapshot?.groupName ?? t`Group`) + : (active?.name ?? t`Choose a conversation`)} + + {inGroup && + (inProject || + activeGroup?.kind !== "main" || + projectGroupMembers.length > 1) && + projectGroupMembers.length > 0 ? ( +
+ {projectGroupMembers.slice(0, 5).map((member) => ( +
+ +
+ ))} +
+ ) : null} +
+ {inGroup && + (inProject || activeGroup?.kind !== "main") && + projectGroupMembers.length > 0 ? ( + + {projectGroupMembers + .map((m) => m.name) + .filter(Boolean) + .join(", ") || t`Group conversation`} + + ) : (!inGroup && active) || + (inGroup && activeGroup?.kind === "main" && coordinatorBot) ? ( ) : inGroup && activeGroup?.kind !== "main" ? ( - {t`Project · sessions & folders`} + {t`Group conversation`} ) : null}
@@ -3717,7 +3832,7 @@ export function ShellPage() {
{ + if (inProject && !active) { + if (showCanvasOnDesktop) { + setPanel(null); + dualSurface.setLayoutMode("stream"); + } else { + dualSurface.setLayoutMode("split"); + } + return; + } // The computer belongs to a bot; from a group, open its lead's computer. if (!active && groupComputerBotId) { navigate(`/app/${groupComputerBotId}`); @@ -3778,7 +3902,7 @@ export function ShellPage() { } }} data-active={ - panel === "computer" && dualSurface.layoutMode !== "stream" + (panel === "computer" || (inProject && !active)) && showCanvasOnDesktop ? "" : undefined } @@ -3829,25 +3953,6 @@ export function ShellPage() {
) : ( <> - {!callOpen && !inGroup && active && transcriptMessages.length === 0 ? ( - - ) : null} - {!callOpen && inGroup ? ( - - ) : null} {active && activeDelegation ? (
{ - const listed = voicesForLocale(voices, uiLocale); + const isGermanLocale = uiLocale.toLowerCase().startsWith("de"); + const hasGermanVoices = voices.some((v) => /^d[fm]_/i.test(v.id)); + const combined = + isGermanLocale && !hasGermanVoices ? [...GERMAN_VOICE_PRESETS, ...voices] : voices; + const listed = voicesForLocale(combined, uiLocale); if (listed.length) return listed; + if (isGermanLocale) return GERMAN_VOICE_PRESETS; return voiceId ? [{ id: voiceId, label: voiceId }] : []; }, [voices, voiceId, uiLocale]); - const speakingReady = Boolean(localConnected && voiceId && status?.ready); + const speakingReady = Boolean( + (localConnected && voiceId && status?.ready) || (autoSpeak && voiceOptions.length > 0), + ); async function ensureLocalVoice(): Promise { if (localConnected) return true; @@ -133,6 +140,9 @@ export function VoiceSettingsOverlay({ await refresh(); return true; } catch (err) { + if (typeof window !== "undefined" && "speechSynthesis" in window) { + return true; + } setError(err instanceof Error ? err.message : t`Could not enable speaking`); return false; } finally { @@ -142,7 +152,12 @@ export function VoiceSettingsOverlay({ async function chooseVoice(nextVoiceId: string) { setVoiceId(nextVoiceId); - if (!localConnected) return; + if (!localConnected) { + if (activeBotId) { + await rpc.bots.update({ botId: activeBotId, voiceId: nextVoiceId }).catch(() => undefined); + } + return; + } markPending("voice"); setError(null); try { @@ -161,6 +176,7 @@ export function VoiceSettingsOverlay({ setError(null); setNotice(null); try { + let chosenVoice = voiceId; if (next) { const connected = await ensureLocalVoice(); if (!connected) { @@ -168,16 +184,29 @@ export function VoiceSettingsOverlay({ return; } const listed = await rpc.voice.voices({ provider: LOCAL_PROVIDER_ID }).catch(() => []); - const nextVoice = voiceIdForLocale(listed, uiLocale, voiceId); + const isGermanLocale = uiLocale.toLowerCase().startsWith("de"); + const pool = + isGermanLocale && !listed.some((v) => /^d[fm]_/i.test(v.id)) + ? [...GERMAN_VOICE_PRESETS, ...listed] + : listed; + const nextVoice = voiceIdForLocale(pool, uiLocale, voiceId); if (nextVoice) { - await rpc.voice.setVoice({ voiceId: nextVoice, provider: LOCAL_PROVIDER_ID }); + chosenVoice = nextVoice; setVoiceId(nextVoice); + await rpc.voice + .setVoice({ voiceId: nextVoice, provider: LOCAL_PROVIDER_ID }) + .catch(() => undefined); } } if (activeBotId) { - await rpc.bots.update({ botId: activeBotId, autoSpeak: next }); + await rpc.bots.update({ + botId: activeBotId, + autoSpeak: next, + ...(chosenVoice ? { voiceId: chosenVoice } : {}), + }); } await refresh(); + if (!voiceId && chosenVoice) setVoiceId(chosenVoice); setNotice(next ? t`Speaking is on.` : t`Speaking is off.`); } catch (err) { setAutoSpeak(!next); @@ -193,7 +222,11 @@ export function VoiceSettingsOverlay({ return; } setError(null); - void speaker.speak(t`Hi, this is how I'll sound when I read replies out loud.`, { + const isGermanVoice = /^d[fm]_/i.test(voice.id) || uiLocale.toLowerCase().startsWith("de"); + const sampleText = isGermanVoice + ? `Hallo, ich bin ${voice.label}. Lass uns direkt miteinander sprechen.` + : t`Hi, this is how I'll sound when we talk.`; + void speaker.speak(sampleText, { voiceId: voice.id, messageId: `${PREVIEW_PREFIX}${voice.id}`, }); @@ -234,7 +267,7 @@ export function VoiceSettingsOverlay({ {autoSpeak ? Can speak : Does not speak}

- Your Oo reads replies aloud when this is on. + Your Oo talks with you naturally when this is on.

{name} + {members && members.length > 0 ? ( +
+ {members.slice(0, 3).map((member) => ( +
+ +
+ ))} +
+ ) : null} {needsYou > 0 ? (
); })} - {running && !encounterMode ? ( + {(running || messages.some((m) => m.id.startsWith("progress:"))) && !encounterMode ? (
0) || message.blocks.some((b) => b.kind === "steps"); const visibleNarrationBlocks = message.blocks - .filter((block) => !isToolActivityBlock(block)) + .filter((block) => !isToolActivityBlock(block) && block.kind !== "progress") .filter((block) => { // Discard broken, truncated text tokens like "Let" or "Ke" when steps exist on this message if (hasSteps && block.kind === "text") { @@ -944,14 +950,18 @@ const MessageView = memo(function MessageView({ const effectiveSpeakerName = speakerName || (message.role === "bot" ? "evOo" : null); const messageContext = ( <> - {effectiveSpeakerName && message.role === "bot" && !encounterMode ? ( -
-
-
+ {effectiveSpeakerName && message.role === "bot" ? ( +
+
-
+ {new Date(message.createdAt).toLocaleTimeString([], { hour: "2-digit", @@ -974,16 +984,24 @@ const MessageView = memo(function MessageView({ })}
- ) : effectiveSpeakerName && message.role === "bot" && encounterMode ? ( -
- {effectiveSpeakerName} - · -