Production readiness phase 1: journeys doc, onboarding persistence, teardown, chat UX

- Add docs/PRODUCTION-READINESS.md with journey matrix and defect backlog
- Persist onboarding draft in session storage across reload
- Localize onboarding discovery main-bot role; German Chief of Staff string
- Destroy team sandboxes and home folders when deleting an account
- Keep composer/transcript visible during queued runs; extend send watchdog
- Add Playwright specs: reload persistence, DE intake, queued progress, console guard

Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-10-07 07:42:02 +00:00
parent 0e1036b2ff
commit c077ff4415
No known key found for this signature in database
17 changed files with 634 additions and 19 deletions

View File

@ -35,6 +35,7 @@ import {
createRunSecretWriter, createRunSecretWriter,
createWebProvider, createWebProvider,
destroyBot, destroyBot,
destroySpaceSandboxes,
EmailEmulator, EmailEmulator,
EncryptedSecretStore, EncryptedSecretStore,
ExpoPushSender, ExpoPushSender,
@ -365,23 +366,34 @@ export async function createApp(
where: { userId }, where: { userId },
select: { id: true, userId: true, spaceId: true, name: true, archivedAt: true }, select: { id: true, userId: true, spaceId: true, name: true, archivedAt: true },
}); });
const lifecycleDeps = {
prisma,
sandbox,
home,
jobs,
artifacts,
dataDir: env.dataDir,
};
const deleteContext = {
operationId: `account-delete:${userId}`,
traceId: `account-delete:${userId}`,
userId,
signal: new AbortController().signal,
};
await Promise.all( await Promise.all(
bots.map((bot) => bots.map((bot) =>
destroyBot( destroyBot(
{ prisma, sandbox, home, jobs, artifacts, dataDir: env.dataDir }, lifecycleDeps,
bot, bot,
{ { ...deleteContext, spaceId: bot.spaceId, botId: bot.id },
operationId: `account-delete:${userId}`,
traceId: `account-delete:${userId}`,
spaceId: bot.spaceId,
userId,
botId: bot.id,
signal: new AbortController().signal,
},
{ deleteMemories: true }, { deleteMemories: true },
), ),
), ),
); );
const spaceIds = [...new Set(bots.map((bot) => bot.spaceId))];
for (const spaceId of spaceIds) {
await destroySpaceSandboxes(lifecycleDeps, { spaceId }, { ...deleteContext, spaceId });
}
await removePiUserSessions(env.dataDir, userId); await removePiUserSessions(env.dataDir, userId);
}, },
}); });

View File

@ -0,0 +1,48 @@
import { expect, test } from "@playwright/test";
import { completeOnboarding, routePatchRpc, signup, waitForChatComposer } from "./helpers";
const viewports = [
{ name: "desktop", width: 1440, height: 900 },
{ name: "mobile", width: 390, height: 844 },
];
for (const viewport of viewports) {
test(`queued run shows waiting status (${viewport.name})`, async ({ page }) => {
await page.setViewportSize({ width: viewport.width, height: viewport.height });
const stamp = Date.now();
await signup(page, `queued-${viewport.name}-${stamp}@rakazo.test`, "password12", "Queue");
await completeOnboarding(page);
await waitForChatComposer(page);
const botId = new URL(page.url()).pathname.split("/").filter(Boolean).at(-1)!;
let holdQueued = false;
await routePatchRpc(page, "threads/get", (json) => {
if (!holdQueued) return json;
const run = {
id: "run-queued-e2e",
botId,
threadId: String(json.threadId ?? "thread-e2e"),
taskId: "task-queued-e2e",
status: "queued",
trigger: "user",
routineId: null,
modelProvider: null,
modelId: null,
error: null,
startedAt: null,
completedAt: null,
createdAt: new Date().toISOString(),
};
return { ...json, run, activeRuns: [run] };
});
holdQueued = true;
const composer = page.getByRole("combobox", { name: /Message|Nachricht/i });
await composer.fill("Hallo");
await composer.press("Enter");
await expect(page.getByRole("status").filter({ hasText: /Queued|Wartet/i })).toBeVisible({
timeout: 20_000,
});
await expect(page.getByRole("button", { name: /Stop|Stopp/i })).toBeVisible();
});
}

View File

@ -0,0 +1,49 @@
import { expect, test } from "@playwright/test";
import { completeOnboarding, openUserSettings, signup, waitForChatComposer } from "./helpers";
const ALLOWED_CONSOLE = [/Failed to load resource.*404/i, /favicon/i, /ERR_ABORTED/i];
function attachConsoleGuard(page: import("@playwright/test").Page) {
const errors: string[] = [];
page.on("console", (msg) => {
if (msg.type() !== "error") return;
const text = msg.text();
if (ALLOWED_CONSOLE.some((pattern) => pattern.test(text))) return;
errors.push(text);
});
page.on("pageerror", (err) => errors.push(err.message));
return errors;
}
test.describe("desktop journeys", () => {
test.use({ viewport: { width: 1440, height: 900 } });
test("sign-up through settings has no unexpected console errors", async ({ page }) => {
const errors = attachConsoleGuard(page);
const stamp = Date.now();
await signup(page, `console-j-${stamp}@rakazo.test`, "password12", "Console QA");
await completeOnboarding(page);
await waitForChatComposer(page);
await page.goto("/app/heute");
await expect(page).toHaveURL(/\/app\/heute/);
await openUserSettings(page);
expect(errors, errors.join("\n")).toEqual([]);
});
});
test.describe("mobile journeys", () => {
test.use({
viewport: { width: 390, height: 844 },
isMobile: true,
hasTouch: true,
});
test("onboarding and chat shell stay console-clean", async ({ page }) => {
const errors = attachConsoleGuard(page);
const stamp = Date.now();
await signup(page, `console-m-${stamp}@rakazo.test`, "password12", "Mobile Console");
await completeOnboarding(page);
await waitForChatComposer(page);
expect(errors, errors.join("\n")).toEqual([]);
});
});

View File

@ -0,0 +1,22 @@
import { expect, test } from "@playwright/test";
import { completeOnboarding, signup } from "./helpers";
test.use({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true });
test("German UI localizes goal placeholder and question in intake", async ({ page }) => {
await page.addInitScript(() => {
Object.defineProperty(navigator, "language", { value: "de-DE", configurable: true });
localStorage.setItem("rakazo.uiLocale", "de");
});
const stamp = Date.now();
await signup(page, `intake-de-${stamp}@rakazo.test`, "password12", "Deutsch Intake");
await completeOnboarding(page, undefined, { mainConversation: true });
const goal = page.locator('[data-testid="choice-card"][data-step="goal"]');
await expect(goal).toContainText("Was möchtest du in den nächsten Wochen erledigt bekommen?");
await expect(
goal.getByRole("textbox", {
name: "Was möchtest du in den nächsten Wochen erledigt bekommen?",
}),
).toHaveAttribute("placeholder", "Dein Ziel");
});

View File

@ -0,0 +1,39 @@
import { expect, test } from "@playwright/test";
import { signup } from "./helpers";
test.describe("desktop", () => {
test.use({ viewport: { width: 1440, height: 900 } });
test("reload during onboarding restores Oo look and step", async ({ page }) => {
const stamp = Date.now();
await signup(page, `onboard-reload-${stamp}@rakazo.test`, "password12", "Reload");
await page.waitForURL(/\/onboarding/, { timeout: 20_000 });
await page.getByRole("button", { name: /^(Continue|Weiter)$/i }).click();
await expect(page.getByTestId("onboarding-name-step")).toBeVisible();
await page.getByTestId("onboarding-oo-name-input").fill("Nova");
await page.reload();
await expect(page.getByTestId("onboarding-name-step")).toBeVisible();
await expect(page.getByTestId("onboarding-oo-name-input")).toHaveValue("Nova");
await expect(page.locator('[data-onboarding-step="name"]')).toBeVisible();
});
});
test.describe("mobile", () => {
test.use({
hasTouch: true,
isMobile: true,
viewport: { width: 390, height: 844 },
});
test("reload during look step keeps category progress", async ({ page }) => {
const stamp = Date.now();
await signup(page, `onboard-reload-m-${stamp}@rakazo.test`, "password12", "Mobile");
await page.waitForURL(/\/onboarding/, { timeout: 20_000 });
await expect(page.locator('[data-onboarding-step="emoji"]')).toBeVisible();
await page.reload();
await expect(page.locator('[data-onboarding-step="emoji"]')).toBeVisible();
await expect(
page.getByRole("heading", { name: /Design your Oo|Gestalte dein Oo/i }),
).toBeVisible();
});
});

View File

@ -1,5 +1,6 @@
import { BotAvatar, GroupAvatar, type GroupAvatarMember } from "@rakazo/ui-web"; import { BotAvatar, GroupAvatar, type GroupAvatarMember } from "@rakazo/ui-web";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { isDoingStatus } from "../../lib/agent-activity";
import { personaWorkingLinesPlain, resolveBotPersona } from "../../lib/bot-persona"; import { personaWorkingLinesPlain, resolveBotPersona } from "../../lib/bot-persona";
import { Shimmer } from "./primitives"; import { Shimmer } from "./primitives";
@ -35,8 +36,17 @@ export function CollaborationMarker({
); );
} }
export function ActiveBotGlyph({ bots, label }: { bots: GroupAvatarMember[]; label: string }) { export function ActiveBotGlyph({
bots,
label,
runStatus,
}: {
bots: GroupAvatarMember[];
label: string;
runStatus?: string;
}) {
const firstBot = bots[0]; const firstBot = bots[0];
const status = runStatus ?? firstBot?.status;
const persona = useMemo( const persona = useMemo(
() => () =>
firstBot?.name firstBot?.name
@ -50,8 +60,9 @@ export function ActiveBotGlyph({ bots, label }: { bots: GroupAvatarMember[]; lab
); );
const workingLines = useMemo(() => { const workingLines = useMemo(() => {
if (!firstBot?.name || !persona) return [label]; if (!firstBot?.name || !persona) return [label];
if (isDoingStatus(status) && status !== "running") return [label];
return [...personaWorkingLinesPlain(persona.archetype, firstBot.name)]; return [...personaWorkingLinesPlain(persona.archetype, firstBot.name)];
}, [firstBot?.name, label, persona]); }, [firstBot?.name, label, persona, status]);
const [phraseIndex, setPhraseIndex] = useState(0); const [phraseIndex, setPhraseIndex] = useState(0);
useEffect(() => { useEffect(() => {

View File

@ -1,5 +1,5 @@
import type { IntegrationSetupState } from "@rakazo/contracts"; import type { IntegrationSetupState } from "@rakazo/contracts";
import { describe, expect, it } from "vitest"; import { beforeEach, describe, expect, it } from "vitest";
import { import {
computeDiscoveryItems, computeDiscoveryItems,
countConfiguredIntegrations, countConfiguredIntegrations,
@ -7,6 +7,12 @@ import {
discoveryProgress, discoveryProgress,
summarizeProfileSignals, summarizeProfileSignals,
} from "./onboarding-discovery.js"; } from "./onboarding-discovery.js";
import { i18n } from "./i18n.js";
beforeEach(() => {
i18n.load("en", { "Chief of Staff": "Chief of Staff" });
i18n.activate("en");
});
describe("countConfiguredIntegrations", () => { describe("countConfiguredIntegrations", () => {
it("counts configured providers", () => { it("counts configured providers", () => {

View File

@ -1,6 +1,12 @@
import type { IntegrationSetupState, Me } from "@rakazo/contracts"; import type { IntegrationSetupState, Me } from "@rakazo/contracts";
import { i18n } from "./i18n";
import { collectLocalProfileSignals, type UserProfileSignals } from "./user-profile-harvest"; import { collectLocalProfileSignals, type UserProfileSignals } from "./user-profile-harvest";
/** Main bot role line shown in onboarding discovery (localized via Lingui catalog). */
function mainBotRoleDetail(): string {
return i18n._("Chief of Staff");
}
export type DiscoverySourceId = "profile-basics" | "connected-tools" | "specialist-crew"; export type DiscoverySourceId = "profile-basics" | "connected-tools" | "specialist-crew";
export type DiscoveryStatus = "pending" | "scanning" | "found" | "skipped"; export type DiscoveryStatus = "pending" | "scanning" | "found" | "skipped";
@ -81,7 +87,7 @@ export function computeDiscoveryItems(context: DiscoveryContext): DiscoveryItem[
const crew: DiscoveryItem = (() => { const crew: DiscoveryItem = (() => {
if (context.crewReady) { if (context.crewReady) {
return { id: "specialist-crew", status: "found", detail: "Chief of Staff" }; return { id: "specialist-crew", status: "found", detail: mainBotRoleDetail() };
} }
if (context.phase === "assembly") { if (context.phase === "assembly") {
return { id: "specialist-crew", status: "scanning" }; return { id: "specialist-crew", status: "scanning" };

View File

@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import {
ONBOARDING_DRAFT_STORAGE_KEY,
clearOnboardingDraft,
persistOnboardingDraft,
readOnboardingDraft,
} from "./onboarding-draft.js";
describe("onboarding draft persistence", () => {
const storage = new Map<string, string>();
const session = {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => {
storage.set(key, value);
},
removeItem: (key: string) => {
storage.delete(key);
},
};
it("round-trips look, name and step through session storage", () => {
persistOnboardingDraft(
{
step: "name",
emojiConfig: { pose: "wave", avatarValue: "oo1:c=FF0000" },
ooName: "Luna",
languageChoice: "de",
currentLocale: "de",
selectedVoiceId: "df_eva",
voiceSkipped: false,
},
session,
);
expect(readOnboardingDraft(session)).toEqual({
step: "name",
emojiConfig: { pose: "wave", avatarValue: "oo1:c=FF0000" },
ooName: "Luna",
languageChoice: "de",
currentLocale: "de",
selectedVoiceId: "df_eva",
voiceSkipped: false,
});
clearOnboardingDraft(session);
expect(storage.has(ONBOARDING_DRAFT_STORAGE_KEY)).toBe(false);
});
});

View File

@ -0,0 +1,93 @@
import type { OnboardingEmojiConfig } from "../components/onboarding/OnboardingOoDesigner";
import type { UiLocale } from "./ui-locale";
export const ONBOARDING_DRAFT_STORAGE_KEY = "rakazo.onboardingDraft";
export type OnboardingStepDraft =
| "emoji"
| "name"
| "language"
| "voice"
| "integrations"
| "scan"
| "bot";
export type OnboardingDraft = {
step: OnboardingStepDraft;
emojiConfig: OnboardingEmojiConfig;
ooName: string;
languageChoice: UiLocale | null;
currentLocale: UiLocale;
selectedVoiceId: string;
voiceSkipped: boolean;
};
function readStorage(
storage: Pick<Storage, "getItem"> | null | undefined,
): Pick<Storage, "getItem"> | null {
if (storage !== undefined) return storage;
return typeof sessionStorage !== "undefined" ? sessionStorage : null;
}
function writeStorage(
storage: Pick<Storage, "setItem" | "removeItem"> | null | undefined,
): Pick<Storage, "setItem" | "removeItem"> | null {
if (storage !== undefined) return storage;
return typeof sessionStorage !== "undefined" ? sessionStorage : null;
}
/** Restore in-progress onboarding after reload or deep link within the same tab. */
export function readOnboardingDraft(
storage: Pick<Storage, "getItem"> | null = readStorage(undefined),
): OnboardingDraft | null {
if (!storage) return null;
try {
const raw = storage.getItem(ONBOARDING_DRAFT_STORAGE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<OnboardingDraft>;
if (
!parsed ||
typeof parsed.step !== "string" ||
!parsed.emojiConfig ||
typeof parsed.emojiConfig.avatarValue !== "string" ||
typeof parsed.emojiConfig.pose !== "string" ||
typeof parsed.ooName !== "string"
) {
return null;
}
return {
step: parsed.step as OnboardingStepDraft,
emojiConfig: parsed.emojiConfig,
ooName: parsed.ooName,
languageChoice: parsed.languageChoice ?? null,
currentLocale: parsed.currentLocale ?? "en",
selectedVoiceId: parsed.selectedVoiceId ?? "",
voiceSkipped: parsed.voiceSkipped ?? false,
};
} catch {
return null;
}
}
export function persistOnboardingDraft(
draft: OnboardingDraft,
storage: Pick<Storage, "setItem"> | null = writeStorage(undefined),
): void {
if (!storage) return;
try {
storage.setItem(ONBOARDING_DRAFT_STORAGE_KEY, JSON.stringify(draft));
} catch {
// Quota / private mode — onboarding still works without persistence.
}
}
export function clearOnboardingDraft(
storage: Pick<Storage, "removeItem"> | null = writeStorage(undefined),
): void {
if (!storage) return;
try {
storage.removeItem(ONBOARDING_DRAFT_STORAGE_KEY);
} catch {
// Ignore storage failures on completion.
}
}

View File

@ -3919,6 +3919,11 @@ msgstr "Push auf diesem Gerät"
msgid "Queued" msgid "Queued"
msgstr "In Warteschlange" msgstr "In Warteschlange"
#. js-lingui-explicit-id
#: src/lib/onboarding-discovery.ts
msgid "Chief of Staff"
msgstr "Stabschef"
#. js-lingui-explicit-id #. js-lingui-explicit-id
#: src/lib/agent-activity.ts #: src/lib/agent-activity.ts
msgid "Queued…" msgid "Queued…"

View File

@ -25,6 +25,11 @@ import {
type OnboardingDiscoveryPhase, type OnboardingDiscoveryPhase,
} from "../lib/onboarding-discovery"; } from "../lib/onboarding-discovery";
import { findLookCategory, lookPresetToAvatarValue } from "../lib/onboarding-look-categories"; import { findLookCategory, lookPresetToAvatarValue } from "../lib/onboarding-look-categories";
import {
clearOnboardingDraft,
persistOnboardingDraft,
readOnboardingDraft,
} from "../lib/onboarding-draft";
import { onboardingProfileUpdate } from "../lib/onboarding-profile"; import { onboardingProfileUpdate } from "../lib/onboarding-profile";
import { rpc, selectSpace } from "../lib/rpc"; import { rpc, selectSpace } from "../lib/rpc";
import { import {
@ -70,6 +75,42 @@ export function OnboardingPage() {
const [currentLocale, setCurrentLocale] = useState<UiLocale>(() => resolveUiLocale()); const [currentLocale, setCurrentLocale] = useState<UiLocale>(() => resolveUiLocale());
const [languageChoice, setLanguageChoice] = useState<UiLocale | null>(null); const [languageChoice, setLanguageChoice] = useState<UiLocale | null>(null);
const activeUiLocale = languageChoice ?? currentLocale; const activeUiLocale = languageChoice ?? currentLocale;
const draftRestoredRef = useRef(false);
useEffect(() => {
if (draftRestoredRef.current) return;
draftRestoredRef.current = true;
const draft = readOnboardingDraft();
if (!draft || draft.step === "bot") return;
setStep(draft.step);
setEmojiConfig(draft.emojiConfig);
setOoName(draft.ooName);
setLanguageChoice(draft.languageChoice);
setCurrentLocale(draft.currentLocale);
setSelectedVoiceId(draft.selectedVoiceId);
setVoiceSkipped(draft.voiceSkipped);
}, []);
useEffect(() => {
if (step === "loading" || step === "bot") return;
persistOnboardingDraft({
step,
emojiConfig,
ooName,
languageChoice,
currentLocale,
selectedVoiceId,
voiceSkipped,
});
}, [
step,
emojiConfig,
ooName,
languageChoice,
currentLocale,
selectedVoiceId,
voiceSkipped,
]);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@ -193,6 +234,7 @@ export function OnboardingPage() {
.list() .list()
.then((navigation) => navigation.main) .then((navigation) => navigation.main)
.catch(() => null); .catch(() => null);
clearOnboardingDraft();
navigate(main ? enterMainConversation(main) : `/app/${bot.id}`); navigate(main ? enterMainConversation(main) : `/app/${bot.id}`);
} catch (err) { } catch (err) {
createStartedRef.current = false; createStartedRef.current = false;

View File

@ -13,6 +13,7 @@ import type {
MainConversationRef, MainConversationRef,
Me, Me,
Routine, Routine,
RunStatus,
Space, Space,
SpaceMemoryConfig, SpaceMemoryConfig,
TaughtSkill, TaughtSkill,
@ -378,13 +379,15 @@ export function ShellPage() {
const [sendError, setSendError] = useState<string | null>(null); const [sendError, setSendError] = useState<string | null>(null);
const [attachmentNotice, setAttachmentNotice] = useState<string | null>(null); const [attachmentNotice, setAttachmentNotice] = useState<string | null>(null);
// Safety watchdog: ensure sending state is never stuck indefinitely if an RPC hangs or errors out // Safety watchdog: clear a stuck send only when no run is active (local models can take longer).
useEffect(() => { useEffect(() => {
if (!sending) return; if (!sending) return;
const timer = setTimeout(() => { const timer = setTimeout(() => {
const runs = activeThreadRuns(snapshotRef.current);
if (runs.some((run) => isActive(run.status as RunStatus))) return;
sendsInFlight.current = 0; sendsInFlight.current = 0;
setSending(false); setSending(false);
}, 10_000); }, 120_000);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [sending]); }, [sending]);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@ -1621,7 +1624,7 @@ export function ShellPage() {
[currentRuns], [currentRuns],
); );
const transcriptRunning = workingRuns.length > 0; const transcriptRunning = workingRuns.length > 0;
const composerRunning = currentRuns.some((run) => isActive(run.status)); const composerRunning = sending || currentRuns.some((run) => isActive(run.status as RunStatus));
const runError = threadRunError(activeSnapshot, dismissedRunErrorIds); const runError = threadRunError(activeSnapshot, dismissedRunErrorIds);
const displayedRunError = !sendError ? runError : null; const displayedRunError = !sendError ? runError : null;
const displayedRunErrorId = displayedRunError ? (activeSnapshot?.run?.id ?? null) : null; const displayedRunErrorId = displayedRunError ? (activeSnapshot?.run?.id ?? null) : null;

View File

@ -39,6 +39,7 @@ import { MediaPlayer } from "../../components/media/MediaPlayer";
import { ThreadCard } from "../../components/ThreadCard"; import { ThreadCard } from "../../components/ThreadCard";
import { SkillDraftCard } from "../../components/teach/SkillDraftCard"; import { SkillDraftCard } from "../../components/teach/SkillDraftCard";
import type { ArtifactTarget } from "../../lib/artifact-open"; import type { ArtifactTarget } from "../../lib/artifact-open";
import { rosterDoingVerb } from "../../lib/agent-activity";
import { copyableMessageText } from "../../lib/message-text"; import { copyableMessageText } from "../../lib/message-text";
import { messageProviderLabel } from "../../lib/messaging"; import { messageProviderLabel } from "../../lib/messaging";
import { withIntakeNoteText } from "../../lib/onboarding-copy"; import { withIntakeNoteText } from "../../lib/onboarding-copy";
@ -138,10 +139,13 @@ export const Transcript = memo(function Transcript({
); );
const transcriptSubordinate = encounterMode && voiceEncounterActive; const transcriptSubordinate = encounterMode && voiceEncounterActive;
const workingBotName = workingBots.length === 1 ? workingBots[0]?.name : undefined; const workingBotName = workingBots.length === 1 ? workingBots[0]?.name : undefined;
const primaryRunStatus = workingBots[0]?.status;
const statusVerb = rosterDoingVerb(primaryRunStatus);
const workingLabel = const workingLabel =
workingBotName != null && workingBotName !== "" statusVerb ??
(workingBotName != null && workingBotName !== ""
? t`${workingBotName} is working` ? t`${workingBotName} is working`
: t`Bots are working`; : t`Bots are working`);
const [quoteDraft, setQuoteDraft] = useState<{ const [quoteDraft, setQuoteDraft] = useState<{
message: ThreadMessage; message: ThreadMessage;
text: string; text: string;
@ -501,7 +505,11 @@ export const Transcript = memo(function Transcript({
block.kind === "progress" && !isToolActivityBlock(block) && Boolean(block.text), block.kind === "progress" && !isToolActivityBlock(block) && Boolean(block.text),
), ),
) ? ( ) ? (
<ActiveBotGlyph bots={workingBots} label={workingLabel} /> <ActiveBotGlyph
bots={workingBots}
label={workingLabel}
runStatus={primaryRunStatus}
/>
) : null} ) : null}
</div> </div>
</div> </div>

View File

@ -0,0 +1,164 @@
# evOo-local production readiness (Phase 1)
Audit of user journeys, automated coverage, known defects, and Phase 2 backlog. Scope: web app (`apps/web`), API, shared packages; mobile (`apps/mobile`) noted where CI does not cover it.
## Product surfaces
| Surface | Entry | Locales in CI |
|--------|--------|----------------|
| Desktop web | ≥768px viewport | EN + DE in targeted specs |
| Mobile web | 390×844, touch | EN + DE in targeted specs |
| API / Postgres journeys | `packages/testkit` | EN |
| Mobile native | Expo app | Maestro smoke only (not full matrix) |
## User journey inventory
Legend: **E2E** = Playwright under `apps/web/e2e/`; **Unit** = vitest in app/packages; **PG** = postgres journey in testkit; **Gap** = missing or partial automation.
### First run & onboarding
| Journey | Routes / UI | Desktop E2E | Mobile E2E | Unit/PG | Gaps |
|--------|-------------|-------------|------------|---------|------|
| Sign up | `/sign-up` | `golden`, `first-run-onboarding-ux` | same | auth PG | — |
| Look category & swipe | `/onboarding` emoji step | `onboarding-oo-swipe`, `onboarding-emoji-first` | swipe spec (desktop viewport) | — | Mobile-specific swipe gestures |
| Name Oo | name step | `first-run-onboarding-ux` | partial | — | — |
| Language | language step | `first-run-onboarding-ux`, `ui-locale` | DE skip label | — | — |
| Voice | voice step | `first-run-onboarding-ux`, `voice` | DE voices | — | Kokoro offline errors |
| Integrations / scan | optional steps | `onboarding-discovery`, `onboarding-complete` | — | onboarding PG | — |
| Reload mid-onboarding | `/onboarding` | **`onboarding-reload-persistence`** (this PR) | same | `onboarding-draft.test` | — |
| Intake cards (focus, goal, rhythm, quiet hours) | `/app/g/…` | `onboarding-intake` | **`onboarding-intake-de`** | onboarding-intake PG | Full DE rhythm/quiet copy |
| Deep link `/onboarding` while signed in | redirect rules | partial via auth specs | — | — | Explicit deep-link matrix |
### Session & auth
| Journey | Routes | E2E | Gaps |
|--------|--------|-----|------|
| Sign in / out | `/sign-in`, `/` | `auth-lifecycle`, `browser-auth` | Tailscale / cookie edge cases |
| Password reset | `/forgot-password`, `/reset-password` | `auth-lifecycle` | — |
| Session unavailable / retry | `/app` loading | `module-recovery` | — |
| Welcome (signed out) | `/` | `smoke` | — |
### Heute (daily brief)
| Journey | Route | E2E | Gaps |
|--------|-------|-----|------|
| Open Heute | `/app/heute` | `heute-brief` | Mobile layout overflow audit |
| Coach / goals list | Heute UI | `heute-brief` | — |
| Deep link reload | `/app/heute` | partial | Console errors on cold load |
### 1:1 & group chat
| Journey | Route | E2E | Gaps |
|--------|-------|-----|------|
| Main conversation | `/app/g/:groupId` | `main-conversation`, `first-conversation` | — |
| Bot thread | `/app/:botId` | many specs | — |
| Send message | composer | `message-send` | — |
| Streaming / progress | transcript | `chat-progress-messages` (fixture), **`chat-queued-progress`** | Real LLM latency (mocked) |
| Queued / slow model UI | composer + transcript | **`chat-queued-progress`** | Multi-run queue depth |
| Errors & retry | composer banner | `run-failure`, `computer-screen-error` | — |
| Stop run | composer | `golden` | — |
| Peer / group | `/app/g/…` | `group-chats`, `peer-messages` | — |
| Console cleanliness | main paths | **`journey-console-errors`** | Full matrix all routes |
### Oos (bots)
| Journey | E2E | Gaps |
|--------|-----|------|
| Create / name / archive / delete | `bot-crud`, `new-bot-ux` | — |
| Switch bot / sidebar | `sidebar-nav`, `unterhaltungen-sidebar` | — |
| Avatar / look | `oo-avatar`, `look-suggestion` | — |
| Capabilities | `bot-capabilities` | — |
| Team vs dedicated computer | `team-computer` | Real docker teardown in PG |
### Projekte
| Journey | E2E | Gaps |
|--------|-----|------|
| Create project space | `spaces`, `adaptive-workspace` | — |
| Bind folder + git | `project-main-agent` | — |
| Timeline / goals | `project-main-agent` | Voice-first goal card |
| Empty project | partial | Dedicated empty-state spec |
### Settings
| Journey | E2E | Gaps |
|--------|-----|------|
| General / account | `settings-shell`, `user-profile` | — |
| Language (UI locale) | `ui-locale` | — |
| Voice | `voice`, `media-settings` | — |
| Goal / intake copy (DE) | **`onboarding-intake-de`** | Profile fact *values* remain EN from server |
| Privacy / delete account | `settings-privacy` | Docker/network cleanup verified in adapters unit; PG journey extension |
| Model connection | `model-settings`, `models-managed` | Offline model banner |
| Notifications / quiet hours | `notification-settings` | — |
| Messaging | `messaging-settings` | — |
### Account & space deletion
| Journey | E2E / PG | Gaps |
|--------|----------|------|
| Delete account (API) | PG journey 11 | **Team sandbox + `data/homes/team-*`** cleanup added in API (this PR); PG assertion Phase 2 |
| Delete empty space | authorization PG | — |
### Empty, error, offline
| Journey | Coverage | Gaps |
|--------|------------|------|
| No model connected | `model-settings`, composer banner | — |
| Server unreachable | `SessionUnavailable` UI | E2E |
| Empty inbox / no bots | partial | — |
| Teaching blocks composer | Shell copy | E2E |
### Navigation, keyboard, a11y
| Journey | E2E | Gaps |
|--------|-----|------|
| Escape closes mobile drawer | `first-run-onboarding-ux` mobile | — |
| Command palette | `command-palette` | — |
| Mention picker keyboard | `mention-picker-keyboard` | — |
| Collapsible sidebar | `collapsible-sidebar-sections`, `mobile-sidebar-swipe` | Touch target audit (44px) |
| Focus management on drawer close | mobile esc test | Broader focus trap review |
### Integrations & MCP
| Journey | E2E | Gaps |
|--------|-----|------|
| Integration setup | `integration-setup` | — |
| MCP OAuth callback | `mcp-oauth` | — |
| GraphQL integrations | `graphql-integrations` | — |
## Prioritized defect list
Severity: **P0** ship blocker · **P1** major UX/trust · **P2** polish · **P3** nice-to-have
### Fixed in Phase 1 (this PR)
| ID | Sev | Issue | Fix |
|----|-----|-------|-----|
| PR1-1 | P1 | Reload during onboarding lost look/name/step | Session draft via `onboarding-draft.ts` |
| PR1-2 | P1 | DE UI: goal placeholder / discovery “Chief of Staff” English | Lingui for discovery; intake DE E2E |
| PR1-3 | P1 | Account delete left team sandbox + homes | `destroySpaceSandboxes` on account delete |
| PR1-4 | P1 | Chat felt idle while queued / slow send | Composer `sending`+run chrome; queued label; 120s watchdog |
| PR1-5 | P2 | Console errors on main journeys | `journey-console-errors` spec |
### Remaining — Phase 2+
| ID | Sev | Issue | Notes |
|----|-----|-------|-------|
| R2-1 | P1 | Profile fact *values* (e.g. focus goal text) stored/displayed in English in DE UI | Server-side localization or locale-aware fact rendering |
| R2-2 | P1 | PG journey: assert filesystem/docker cleanup after account delete | Extend journey 11 with fake sandbox + temp data dir |
| R2-3 | P2 | Five persona-test console/network errors (root causes) | Need symbolicated list from persona run logs |
| R2-4 | P2 | 390px layout overflow on long German strings | Audit Heute, settings, project timeline |
| R2-5 | P2 | Touch targets &lt;44px in dense sidebar rows | Measure `sidebar-unterhaltungen` rows |
| R2-6 | P2 | Deep link every route while signed out/in | Table-driven Playwright matrix |
| R2-7 | P2 | Offline / model-not-connected on mobile web | No E2E |
| R2-8 | P3 | Mobile native parity for onboarding reload | Expo secure store draft |
| R2-9 | P3 | Intake card English questions in non-EN tests | Extend locale matrix beyond DE |
| R2-10 | P3 | NPS/SUS follow-ups from persona round 2 | Product research, not CI |
## CI gates (Product)
Lint · Typecheck · Unit · Postgres journeys · Production builds · Web E2E (Playwright). Desktop Electron E2E runs in CI virtual display only.
## How to extend this doc
When adding a journey spec, update the table row and move defects from **Remaining** to **Fixed** with PR link. Keep user-visible copy quoted in PR descriptions when introducing new strings.

View File

@ -12,6 +12,7 @@ import {
archiveSpawnedBot, archiveSpawnedBot,
confirmSpawnedBotName, confirmSpawnedBotName,
destroyBot, destroyBot,
destroySpaceSandboxes,
spawnBot, spawnBot,
} from "./child-bots.js"; } from "./child-bots.js";
@ -614,6 +615,39 @@ describe("destroyBot", () => {
}); });
}); });
describe("destroySpaceSandboxes", () => {
it("destroys every provider-backed computer in the space", async () => {
const destroy = vi.fn().mockResolvedValue(undefined);
const updateMany = vi.fn().mockResolvedValue({ count: 1 });
const prisma = {
computer: {
findMany: vi.fn().mockResolvedValue([
{
id: "team-1",
homeKey: "team-space-1",
kind: "fake",
providerRef: "prov-team",
},
]),
updateMany,
},
} as unknown as PrismaClient;
await destroySpaceSandboxes(
{
prisma,
sandbox: { destroy } as unknown as SandboxProvider,
home: { resolvePath: vi.fn() } as unknown as AgentHomeStore,
jobs: { cancel: vi.fn() } as unknown as JobPublisher,
dataDir: "/tmp/rakazo-space-teardown",
},
{ spaceId: "space-1" },
context,
);
expect(destroy).toHaveBeenCalledOnce();
expect(updateMany).toHaveBeenCalledOnce();
});
});
describe("archiveBot", () => { describe("archiveBot", () => {
it("stops work and routines while preserving the bot", async () => { it("stops work and routines while preserving the bot", async () => {
const updateBot = vi.fn().mockResolvedValue({}); const updateBot = vi.fn().mockResolvedValue({});

View File

@ -337,6 +337,33 @@ export async function archiveBot(
} }
} }
/** Tear down every sandbox and home folder still tied to a space (e.g. shared team computers). */
export async function destroySpaceSandboxes(
deps: BotLifecycleDeps,
input: { spaceId: string },
context: AdapterContext,
) {
const computers = await deps.prisma.computer.findMany({
where: { spaceId: input.spaceId, providerRef: { not: null } },
select: { id: true, homeKey: true, kind: true, providerRef: true },
});
for (const computer of computers) {
if (!computer.providerRef) continue;
const ref = toComputerRef(computer);
await deps.sandbox.destroy(ref, { ...context, botId: computer.homeKey }).catch(() => undefined);
await rm(resolveAgentHomePath(deps.home, computer.homeKey, deps.dataDir ?? "./data"), {
recursive: true,
force: true,
}).catch(() => undefined);
}
if (computers.length) {
await deps.prisma.computer.updateMany({
where: { spaceId: input.spaceId, id: { in: computers.map((computer) => computer.id) } },
data: { state: "stopped", providerRef: null },
});
}
}
export async function destroyBot( export async function destroyBot(
deps: BotLifecycleDeps, deps: BotLifecycleDeps,
bot: LifecycleBot, bot: LifecycleBot,