diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index 5d978f9..32c0707 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -4960,10 +4960,14 @@ async function meDto(deps: RouterDeps, actor: Actor): Promise { } async function modelSetup(deps: RouterDeps, actor: Actor) { - const [credential, settings] = await Promise.all([ + let [credential, settings] = await Promise.all([ findDefaultModelCredential(deps.prisma, actor), deps.prisma.deploymentSettings.findUnique({ where: { id: "default" } }), ]); + // Fallback: if no space-scoped credential, look for any user credential with a local provider + if (!credential) { + credential = await findModelCredential(deps.prisma, actor, "local"); + } const hasDeployment = Boolean(deps.env.deploymentModelKey); return { credential, diff --git a/apps/web/e2e/auth-lifecycle.spec.ts b/apps/web/e2e/auth-lifecycle.spec.ts index 3ef6599..60440f4 100644 --- a/apps/web/e2e/auth-lifecycle.spec.ts +++ b/apps/web/e2e/auth-lifecycle.spec.ts @@ -78,7 +78,7 @@ test("logout protects bot deep links and sign-in restores the session", async ({ await expect(page.getByText(/Your team of always-on agents/)).toBeVisible(); await page.getByRole("button", { name: /Sign up/ }).click(); await expect(page).toHaveURL(/\/sign-up$/); - await expect(page.getByRole("heading", { name: "Create your Rakazo" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Create your EvOo" })).toBeVisible(); await page.goto("/"); await captureScreenshot(page, testInfo, "37-logged-out-welcome"); diff --git a/apps/web/e2e/helpers.ts b/apps/web/e2e/helpers.ts index d7d7911..cf7f3a8 100644 --- a/apps/web/e2e/helpers.ts +++ b/apps/web/e2e/helpers.ts @@ -27,11 +27,16 @@ export async function rpc(page: Page, procedure: string, body: unknown): Prom export async function completeOnboarding(page: Page, testInfo?: TestInfo) { await page.waitForURL(/\/(onboarding|app)/, { timeout: 20_000 }); - // Emoji step: just skip by clicking Continue - const designHeading = page.getByRole("heading", { name: /Design your Oo/i }); - if (await designHeading.isVisible({ timeout: 5000 }).catch(() => false)) { - await page.getByRole("button", { name: /Continue/i }).first().click(); - } + // Setup progress / "Don't stress" page — wait for it to settle + const setupProgress = page.locator('progress[aria-label="Setup progress"]'); + await setupProgress.waitFor({ state: "hidden", timeout: 30_000 }).catch(() => {}); + + // Emoji step: "Design your Oo" — skip by clicking Continue + // Wait for the design page to be fully interactive + await page.getByText(/Design your Oo/i).first().waitFor({ timeout: 10000 }); + await page.getByRole("button", { name: "Continue" }).click(); + // Wait for next page or URL change + await page.waitForTimeout(1000); // Preferences step: just skip const prefHeading = page.getByRole("heading", { name: /Tell us about yourself/i }); @@ -42,7 +47,6 @@ export async function completeOnboarding(page: Page, testInfo?: TestInfo) { // Language step: skip (defaults to English) const langHeading = page.getByRole("heading", { name: /Choose your language/i }); if (await langHeading.isVisible({ timeout: 5000 }).catch(() => false)) { - // Try Skip first, otherwise just continue const skipBtn = page.getByRole("button", { name: /Skip/i }); if (await skipBtn.isVisible({ timeout: 2000 }).catch(() => false)) { await skipBtn.click(); @@ -61,20 +65,45 @@ export async function completeOnboarding(page: Page, testInfo?: TestInfo) { // first bot is created automatically — land in Chief's chat with no form. const integrations = page.getByRole("heading", { name: "Server integrations", exact: true }); const chief = page.getByText("Chief").first(); - await integrations.or(chief).or(page.getByText("Opening chat…")).waitFor({ timeout: 20_000 }); - if ((await chief.isVisible().catch(() => false)) && page.url().includes("/app")) { - if (testInfo) { - await captureScreenshot(page, testInfo, "03-create-first-bot"); - await captureScreenshot(page, testInfo, "06-onboarding-complete"); - } - return; - } - if (await integrations.isVisible().catch(() => false)) { + const openingChat = page.getByText("Opening chat…"); + + // Try each target sequentially (or() can fail when none match) + let landed = false; + if (await integrations.isVisible({ timeout: 5000 }).catch(() => false)) { if (testInfo) await captureScreenshot(page, testInfo, "02-connect-apps"); await page.getByRole("button", { name: "Skip", exact: true }).click(); + landed = true; } - await page.waitForURL(/\/app\//, { timeout: 20_000 }); - await expect(page.getByText("Chief").first()).toBeVisible(); + if (!landed && await chief.isVisible({ timeout: 5000 }).catch(() => false)) { + landed = true; + } + if (!landed && await openingChat.isVisible({ timeout: 5000 }).catch(() => false)) { + landed = true; + } + + if (landed) { + await page.waitForURL(/\/app(\/[^/]+)?/, { timeout: 20_000 }).catch(() => {}); + } + + // Final: ensure we're in the chat with Chief + await page.waitForURL(/\/app(\/[^/]+)?/, { timeout: 20_000 }).catch(() => {}); + await expect(chief).toBeVisible({ timeout: 15_000 }); + + // Dismiss any onboarding overlay (e.g. "What do you want me on first?") + // Try multiple strategies: Escape key, close button, dismiss button + const overlay = page.locator('[role="dialog"], [role="presentation"], [data-overlay]'); + const overlayVisible = await overlay.count({ timeout: 3000 }).catch(() => 0); + if (overlayVisible > 0) { + await page.keyboard.press('Escape'); + await page.waitForTimeout(500); + } + // Also try clicking X + const closeBtn = page.getByRole("button", { name: "X" }).first(); + if (await closeBtn.isVisible({ timeout: 1000 }).catch(() => false)) { + await closeBtn.click(); + await page.waitForTimeout(500); + } + if (testInfo) { await captureScreenshot(page, testInfo, "03-create-first-bot"); await captureScreenshot(page, testInfo, "06-onboarding-complete"); @@ -89,7 +118,7 @@ export async function signup( testInfo?: TestInfo, ) { await page.goto("/sign-up"); - await expect(page.getByRole("heading", { name: "Create your Rakazo" })).toBeVisible(); + await expect(page.getByRole("heading", { name: "Create your EvOo" })).toBeVisible(); if (testInfo) await captureScreenshot(page, testInfo, "01-sign-up"); await page.getByPlaceholder("Your name").fill(name); await page.getByPlaceholder("Your email address").fill(email); diff --git a/apps/web/e2e/message-send.spec.ts b/apps/web/e2e/message-send.spec.ts new file mode 100644 index 0000000..a9d6654 --- /dev/null +++ b/apps/web/e2e/message-send.spec.ts @@ -0,0 +1,49 @@ +/** + * E2E: Message composer is available and interactive. + */ +import { expect, test } from "@playwright/test"; +import { captureScreenshot, completeOnboarding, createNamedBot, signup } from "./helpers"; + +test.describe.configure({ mode: "serial" }); + +test("message composer is available and interactive", async ({ page }) => { + const testInfo = test.info(); + const stamp = Date.now(); + await signup(page, `msgsend-${stamp}@rakazo.test`, "password12", "MsgSend"); + await completeOnboarding(page); + await expect(page.getByText("Chief").first()).toBeVisible(); + + // Create a fresh bot via RPC + const botId = await createNamedBot(page, `TestBot-${stamp}`); + await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible({ + timeout: 10_000, + }); + + const composer = page.getByRole("combobox", { name: "Message" }); + await composer.click(); + + // Use fill which works on combobox inputs + await composer.fill("Hello test"); + + // Verify the value was set (check input value, not text content) + const inputValue = await composer.inputValue(); + expect(inputValue).toContain("Hello test"); + + // Send via Enter + await page.keyboard.press("Enter"); + await page.waitForTimeout(2000); + + // Either the message appears in chat OR we see a model error + const messageInChat = page.getByText("Hello test").first(); + const modelError = page.getByText(/Connect a model/i); + + const [inChat, hasError] = await Promise.all([ + messageInChat.isVisible({ timeout: 5000 }).catch(() => false), + modelError.isVisible({ timeout: 3000 }).catch(() => false), + ]); + + // At least one must be true + expect(inChat || hasError).toBe(true); + + await captureScreenshot(page, testInfo, "msgsend-after-submit"); +}); diff --git a/apps/web/e2e/onboarding-complete.spec.ts b/apps/web/e2e/onboarding-complete.spec.ts new file mode 100644 index 0000000..abfcfc5 --- /dev/null +++ b/apps/web/e2e/onboarding-complete.spec.ts @@ -0,0 +1,26 @@ +/** + * E2E: Complete onboarding flow end-to-end. + */ +import { expect, test } from "@playwright/test"; +import { captureScreenshot, completeOnboarding, signup } from "./helpers"; + +test.describe.configure({ mode: "serial" }); + +test("complete onboarding flow to chat", async ({ page }) => { + const testInfo = test.info(); + const stamp = Date.now(); + await signup(page, `onboard-${stamp}@rakazo.test`, "password12", "Onboard"); + await completeOnboarding(page); + await expect(page.getByText("Chief").first()).toBeVisible(); + await expect(page.getByRole("combobox", { name: "Message" })).toBeVisible(); + await captureScreenshot(page, testInfo, "onboard-complete"); +}); + +test("onboarding with avatar customization step shown", async ({ page }) => { + const testInfo = test.info(); + const stamp = Date.now(); + await signup(page, `avatar-${stamp}@rakazo.test`, "password12", "Avatar"); + await completeOnboarding(page); + await expect(page.getByText("Chief").first()).toBeVisible(); + await captureScreenshot(page, testInfo, "avatar-complete"); +}); diff --git a/apps/web/e2e/sidebar-integrations-removed.spec.ts b/apps/web/e2e/sidebar-integrations-removed.spec.ts new file mode 100644 index 0000000..52e0d32 --- /dev/null +++ b/apps/web/e2e/sidebar-integrations-removed.spec.ts @@ -0,0 +1,44 @@ +/** + * Sidebar: "Integrationen" button must NOT appear. + * + * The Integrations panel was removed from the sidebar. This test verifies + * that neither the Integrations button nor any integration-related content + * renders in the sidebar. + */ +import { expect, test } from "@playwright/test"; +import { captureScreenshot, completeOnboarding, signup } from "./helpers"; + +test.describe.configure({ mode: "serial" }); + +test("sidebar must not show an Integrations button or integration entries", async ({ page }) => { + const testInfo = test.info(); + const stamp = Date.now(); + await signup(page, `sidebar-${stamp}@rakazo.test`, "password12", "Sidebar"); + await completeOnboarding(page); + await expect(page.getByText("Chief").first()).toBeVisible(); + + await captureScreenshot(page, testInfo, "sidebar-onboarding-done"); + + // --- Sidebar assertions --- + + // 1. No "Integrationen" / "Integrations" button anywhere in the sidebar + const sidebar = page.locator("aside").first(); + const integrationsBtn = sidebar.getByRole("button", { name: /Integration(en)?/i }); + await expect(integrationsBtn).toHaveCount(0, { + timeout: 5000, + }); + + // 2. No integration-related text content in sidebar + const integrationText = sidebar.getByText(/Integration(en)?/i); + await expect(integrationText).toHaveCount(0, { + timeout: 5000, + }); + + // 3. LayoutGrid icon (the Integrations button icon) must not be in sidebar + const layoutGrid = sidebar.locator('[aria-label*="Integration"]'); + await expect(layoutGrid).toHaveCount(0, { + timeout: 5000, + }); + + await captureScreenshot(page, testInfo, "sidebar-no-integrations"); +}); diff --git a/apps/web/e2e/sidebar-nav.spec.ts b/apps/web/e2e/sidebar-nav.spec.ts new file mode 100644 index 0000000..0f59c58 --- /dev/null +++ b/apps/web/e2e/sidebar-nav.spec.ts @@ -0,0 +1,54 @@ +/** + * E2E: Sidebar navigation elements. + * + * Verifies the expected sidebar sections appear and that Integrations is gone. + */ +import { expect, test } from "@playwright/test"; +import { captureScreenshot, completeOnboarding, signup } from "./helpers"; + +test.describe.configure({ mode: "serial" }); + +test("sidebar has expected navigation items and no integrations", async ({ page }) => { + const testInfo = test.info(); + const stamp = Date.now(); + await signup(page, `snnav-${stamp}@rakazo.test`, "password12", "SnNav"); + await completeOnboarding(page); + await expect(page.getByText("Chief").first()).toBeVisible(); + + const sidebar = page.locator("aside").first(); + await captureScreenshot(page, testInfo, "sidebar-nav-before"); + + // Actual sidebar structure (verified by snapshot): + // - button "Activity" (top action) + // - textbox "Search" (search input) + // - button "Heute" (today) + // - text "Unterhaltungen" (conversations section header) + // - button "Live Trading" + // - NO "Integration(en)" button or text + + // Search: it's a textbox, not a button + const searchInput = sidebar.locator('input[placeholder="Search"], textbox[name="Search"]'); + await expect(searchInput).toBeVisible({ timeout: 5000 }); + + // "Heute" (Today) + const todayBtn = sidebar.getByRole("button", { name: "Heute" }); + await expect(todayBtn).toBeVisible({ timeout: 5000 }); + + // "Unterhaltungen" is plain text, not a button + const conversationsText = sidebar.getByText("Unterhaltungen"); + await expect(conversationsText).toBeVisible({ timeout: 5000 }); + + // "Live Trading" + const liveTradingBtn = sidebar.getByRole("button", { name: "Live Trading" }); + await expect(liveTradingBtn).toBeVisible({ timeout: 5000 }); + + // No "Integration(en)" button + const integrationBtn = sidebar.getByRole("button", { name: /Integration(en)?/i }); + await expect(integrationBtn).toHaveCount(0, { timeout: 5000 }); + + // No "Integration(en)" text anywhere in sidebar + const integrationText = sidebar.getByText(/Integration(en)?/i); + await expect(integrationText).toHaveCount(0, { timeout: 5000 }); + + await captureScreenshot(page, testInfo, "sidebar-nav-after"); +}); diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts index 7ea8b5a..d20d364 100644 --- a/apps/web/playwright.config.ts +++ b/apps/web/playwright.config.ts @@ -26,10 +26,12 @@ export default defineConfig({ video: "retain-on-failure", }, projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], - webServer: { - command: "pnpm dev", - url: baseURL, - reuseExistingServer: !process.env.CI, - timeout: 120_000, - }, + webServer: process.env.PLAYWRIGHT_NO_SERVER + ? undefined + : { + command: "pnpm dev", + url: baseURL, + reuseExistingServer: !process.env.CI, + timeout: 120_000, + }, }); diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index 683c2b7..4f70d6a 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -108,6 +108,7 @@ import { Pencil, Plus, Puzzle, + RefreshCw, Reply, Search, Settings, @@ -173,6 +174,7 @@ import { } from "../lib/browser-notifications"; import { loadComputerScreen } from "../lib/computer-screen"; import { desktopBridge } from "../lib/desktop"; +import { browserPreviewActivity } from "../lib/browser-preview-context"; import { scheduleFocusPrompt } from "../lib/focus-prompt"; import { isWorkingRunStatus, @@ -287,6 +289,7 @@ type Panel = | "create" | "create-group" | "group-settings" + | "browser" | null; type PendingAttachment = { @@ -430,6 +433,46 @@ export function ShellPage() { setBotsSidebarCollapsed(readBotsSidebarCollapsed(userId)); }, [userId]); + // ── Browser Preview ────────────────────────────────────────────── + const bridge = desktopBridge(); + const [isDesktop] = useState(Boolean(bridge?.browserPreview)); + const [panel, setPanel] = useState(null); + const [browserUrl, setBrowserUrl] = useState("http://localhost:5173"); + const [devServerRunning, setDevServerRunning] = useState(false); + const [browserLoading, setBrowserLoading] = useState(false); + const [agentActivities, setAgentActivities] = useState>(new Map()); + + const [showCreateBotModal, setShowCreateBotModal] = useState(false); + + // Browser Preview lifecycle + useEffect(() => { + const bp = bridge?.browserPreview; + if (!bp) return; + const unsubNav = bp.onNavigate((url) => setBrowserUrl(url)); + const unsubLoading = bp.onLoadingProgress((p) => setBrowserLoading(p < 1)); + bp.getState().then((s) => { setBrowserUrl(s.url); setDevServerRunning(s.devServerRunning); }).catch(() => {}); + return () => { unsubNav(); unsubLoading(); }; + }, [bridge]); + + const startDevServer = useCallback(async () => { + try { await bridge?.browserPreview?.devServer.start(); setDevServerRunning(true); } catch { /* */ } + }, [bridge]); + const stopDevServer = useCallback(async () => { + try { await bridge?.browserPreview?.devServer.stop(); setDevServerRunning(false); } catch { /* */ } + }, [bridge]); + const navigateBrowser = useCallback((url: string) => { bridge?.browserPreview?.navigate(url).catch(() => {}); }, [bridge]); + const reloadBrowser = useCallback(() => { bridge?.browserPreview?.reload().catch(() => {}); }, [bridge]); + + // Agent activity → browser preview cursors + useEffect(() => { + const unsub = browserPreviewActivity.subscribe((acts) => { + const snap = new Map(); + for (const [id, a] of acts) snap.set(id, { x: a.x, y: a.y, label: a.label }); + setAgentActivities(snap); + }); + return unsub; + }, []); + const [query, setQuery] = useState(""); const [searchHits, setSearchHits] = useState([]); const [searchLoading, setSearchLoading] = useState(false); @@ -451,7 +494,6 @@ export function ShellPage() { return () => clearTimeout(timer); }, [sending]); const fileInputRef = useRef(null); - const [panel, setPanel] = useState(null); const [peerConversation, setPeerConversation] = useState<{ peerBotId: string; peerBotName: string; @@ -1929,6 +1971,21 @@ export function ShellPage() { status: member.status, })); }, [activeSnapshot, enrichStageMember, inGroup, resolveTranscriptBot, workingRuns]); + + // Working bots → browser preview activity cursors + useEffect(() => { + if (workingBots.length === 0) return; + for (const bot of workingBots) { + if (!bot.botId) continue; + const label = bot.name ?? bot.botId.slice(0, 8); + browserPreviewActivity.set(bot.botId, 50, 40, label); + } + const activeBotIds = new Set(workingBots.map((b) => b.botId).filter(Boolean)); + for (const id of browserPreviewActivity.snapshot().keys()) { + if (!activeBotIds.has(id)) browserPreviewActivity.clear(id); + } + }, [workingBots]); + const encounterMode = Boolean((active || activeGroup) && !showProjectTimeline && !callOpen); const voiceEncounterActive = encounterMode || callOpen || voiceEntry; const voiceCallBot = useMemo(() => { @@ -3874,18 +3931,6 @@ export function ShellPage() { {tradingMode === "live" ? "Live Trading" : "Paper Sim"} - {!active && !activeGroup && initialBotsLoaded ? (
-
- ) : showProjectTimeline ? ( + ) : showProjectTimeline && bots.length > 0 ? ( openProjectVoiceFirst(goalGroupId)} /> + ) : showProjectTimeline && bots.length === 0 ? ( +
+

+ Erstelle zuerst einen Bot, um Projekte starten zu können. +

+ +
) : ( <> {!callOpen && active && !inGroup ? ( @@ -4322,10 +4377,10 @@ export function ShellPage() { data-testid="side-panel" data-panel={panel ?? "closed"} style={{ - width: panel && (active || activeGroup || panel === "create") ? rightSidebarWidth : 0, + width: panel && (active || activeGroup || panel === "create" || panel === "browser") ? rightSidebarWidth : 0, }} className={`absolute inset-y-0 end-0 z-20 flex min-h-0 shrink-0 flex-col overflow-hidden bg-background md:rounded-2xl md:border md:border-white/10 md:bg-[#0A0A0C] md:shadow-2xl md:relative ${ - panel && (active || activeGroup || panel === "create") + panel && (active || activeGroup || panel === "create" || panel === "browser") ? "max-w-[384px] md:max-w-none opacity-100" : "pointer-events-none w-0 opacity-0 md:opacity-0 md:w-0" } ${!isResizingRight ? "transition-[width,opacity] duration-300 ease-[cubic-bezier(0.16,1,0.3,1)]" : ""}`} @@ -4374,6 +4429,17 @@ export function ShellPage() { ) : null} + {!panel && active ? ( + + ) : null} + setBrowserUrl(e.target.value)} + onBlur={() => navigateBrowser(browserUrl)} + onKeyDown={(e) => { if (e.key === "Enter") navigateBrowser(browserUrl); }} + placeholder="http://localhost:5173" + className="flex-1 rounded-md bg-muted/60 px-2.5 py-1 text-xs text-foreground placeholder:text-muted-foreground/60 outline-none focus:ring-1 focus:ring-ring/50" + /> + {isDesktop ? ( + devServerRunning ? ( + + + Dev + + ) : ( + + ) + ) : null} + {browserLoading && ( +
+
+
+ )} +
+
+
+ {agentActivities.size > 0 && ( +
+ {Array.from(agentActivities.entries()).map(([agentId, activity]) => { + const cx = Math.min(100, Math.max(0, activity.x)) + "%"; + const cy = Math.min(100, Math.max(0, activity.y)) + "%"; + return ( +
+
+
+
+ {activity.label} +
+
+
+ ); + })} +
+ )} +
+
+
+ ) : null} {panel === "computer" && active ? (
createBot(input)} /> ) : null} + {/* Centered Create Bot Modal */} + {showCreateBotModal && createPortal( +
setShowCreateBotModal(false)} + > +
e.stopPropagation()} + > +
+

+ Create new Bot +

+ +
+ setShowCreateBotModal(false)} + onCreate={async (input) => { + await createBot(input); + }} + /> +
+
, + document.body, + )} {panel === "settings" && active ? ( { strictPort: true, allowedHosts: true, proxy: { - "/api": { target: api, changeOrigin: true }, - "/rpc": { target: api, changeOrigin: true }, + "/api": { + target: api, + changeOrigin: true, + configure: (proxy) => { + proxy.on("proxyRes", (res) => { + res.headers["access-control-allow-origin"] = "*"; + res.headers["access-control-allow-credentials"] = "true"; + }); + }, + }, + "/rpc": { + target: api, + changeOrigin: true, + configure: (proxy) => { + proxy.on("proxyRes", (res) => { + res.headers["access-control-allow-origin"] = "*"; + res.headers["access-control-allow-credentials"] = "true"; + }); + }, + }, }, + cors: true, }, preview: { host: "0.0.0.0",