diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index e387428..5611d49 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -617,11 +617,7 @@ export async function createApp( actor, signal: c.req.raw.signal, userAgent: c.req.header("user-agent"), - webOrigin: requestWebOrigin( - c.req.header("origin"), - env.webOrigin, - env.extraTrustedOrigins, - ), + webOrigin: requestWebOrigin(c.req.header("origin"), env.webOrigin, env.extraTrustedOrigins), }, }); if (matched) return c.newResponse(response.body, response); diff --git a/apps/api/src/thread-target.ts b/apps/api/src/thread-target.ts index 24eb60c..3d6fe4e 100644 --- a/apps/api/src/thread-target.ts +++ b/apps/api/src/thread-target.ts @@ -1542,7 +1542,12 @@ export async function removeQueuedMessage( if (result.removed) { // Notify subscribers so the UI updates - await deps.events.notify(target.threadId, undefined).catch(() => undefined); + const latest = await deps.prisma.event.findFirst({ + where: { threadId: target.threadId }, + orderBy: { seq: "desc" }, + select: { seq: true }, + }); + await deps.events.notify(target.threadId, latest?.seq ?? 0).catch(() => undefined); } return { ok: true as const, removed: result.removed }; diff --git a/apps/mobile/lib/locales/de.ts b/apps/mobile/lib/locales/de.ts index d10ae63..ac8dff8 100644 --- a/apps/mobile/lib/locales/de.ts +++ b/apps/mobile/lib/locales/de.ts @@ -261,6 +261,7 @@ export const DE_MESSAGES: Record = { Eyeshadow: "Lidschatten", Failed: "Fehlgeschlagen", "Failed to refresh": "Aktualisieren fehlgeschlagen", + "Failed to remove message": "Nachricht konnte nicht entfernt werden", "Failed to send message": "Nachricht konnte nicht gesendet werden", "Failed to stop work": "Arbeit konnte nicht gestoppt werden", "Fewer {label}": "Weniger {label}", diff --git a/apps/mobile/lib/locales/ru.ts b/apps/mobile/lib/locales/ru.ts index 301bb7c..70f4e44 100644 --- a/apps/mobile/lib/locales/ru.ts +++ b/apps/mobile/lib/locales/ru.ts @@ -274,6 +274,7 @@ export const RU_MESSAGES: Record = { "Executor token": "Токен Executor", Failed: "Ошибка", "Failed to refresh": "Не удалось обновить", + "Failed to remove message": "Не удалось удалить сообщение", "Failed to send message": "Не удалось отправить сообщение", "Failed to stop work": "Не удалось остановить работу", File: "Файл", diff --git a/apps/mobile/lib/locales/zh.ts b/apps/mobile/lib/locales/zh.ts index f1a8c23..96e6b95 100644 --- a/apps/mobile/lib/locales/zh.ts +++ b/apps/mobile/lib/locales/zh.ts @@ -259,6 +259,7 @@ export const ZH_MESSAGES: Record = { "Executor token": "Executor 令牌", Failed: "失败", "Failed to refresh": "刷新失败", + "Failed to remove message": "删除消息失败", "Failed to send message": "发送消息失败", "Failed to stop work": "停止工作失败", File: "文件", diff --git a/apps/mobile/package.json b/apps/mobile/package.json index fa9ab15..4a7ed97 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -18,14 +18,14 @@ }, "dependencies": { "@expo/metro-runtime": "57.0.17", - "@expo/ui": "57.0.25", + "@expo/ui": "57.0.26", "@rakazo/chat-ui": "workspace:*", "@rakazo/contracts": "workspace:*", "@rakazo/core": "workspace:*", "@rakazo/ui-tokens": "workspace:*", "@react-native-vector-icons/ionicons": "13.1.4", "@react-native-vector-icons/material-design-icons": "13.1.4", - "expo": "57.0.30", + "expo": "57.0.31", "expo-application": "57.0.3", "expo-audio": "57.0.5", "expo-clipboard": "57.0.2", @@ -37,11 +37,11 @@ "expo-keep-awake": "57.0.2", "expo-linking": "57.0.13", "expo-localization": "57.0.2", - "expo-modules-core": "57.0.23", - "expo-notifications": "57.0.23", - "expo-router": "57.0.28", + "expo-modules-core": "57.0.24", + "expo-notifications": "57.0.24", + "expo-router": "57.0.29", "expo-secure-store": "57.0.4", - "expo-sharing": "57.0.23", + "expo-sharing": "57.0.24", "expo-splash-screen": "57.0.10", "expo-status-bar": "57.0.1", "expo-symbols": "57.0.3", diff --git a/apps/web/e2e/adaptive-workspace.spec.ts b/apps/web/e2e/adaptive-workspace.spec.ts index 5b85370..16b66f0 100644 --- a/apps/web/e2e/adaptive-workspace.spec.ts +++ b/apps/web/e2e/adaptive-workspace.spec.ts @@ -31,7 +31,7 @@ test("the bot's screen opens beside the chat while it works and steps back after await expect(sidePanel).toHaveAttribute("data-panel", "computer"); await expect(sidePanel).toHaveAttribute("data-auto", ""); - await expect(page.getByTestId("computer-preview").locator("iframe")).toHaveAttribute( + await expect(page.getByTestId("oo-computer-browser-viewport").locator("iframe")).toHaveAttribute( "src", SCREEN_URL, ); diff --git a/apps/web/e2e/agent-cursor.spec.ts b/apps/web/e2e/agent-cursor.spec.ts index 052a821..07b7933 100644 --- a/apps/web/e2e/agent-cursor.spec.ts +++ b/apps/web/e2e/agent-cursor.spec.ts @@ -26,16 +26,28 @@ test("the bot's cursor glides to where it clicks on its live screen", async ({ ); await page.getByTitle("Agent computer").click(); - const preview = page.getByTestId("computer-preview"); + const preview = page.getByTestId("oo-computer-browser-viewport"); await expect(preview.locator("iframe")).toHaveAttribute("src", SCREEN_URL); const cursor = preview.getByTestId("agent-cursor"); await expect(cursor).toHaveCSS("opacity", "0"); + // The click and typing last about a second, so record every action rather than racing a poll. + await cursor.evaluate((node) => { + const seen: string[] = []; + (window as unknown as { cursorActions: string[] }).cursorActions = seen; + new MutationObserver(() => seen.push(node.getAttribute("data-action") ?? "")).observe(node, { + attributeFilter: ["data-action"], + }); + }); // The scripted runtime clicks the screen center (640, 400) and types. await rpc(page, "threads/send", { botId, text: "use your screen and type cursor-demo" }); await expect(cursor).toHaveCSS("opacity", "1"); - await expect(cursor).toHaveAttribute("data-action", /click|type/); + await expect + .poll(() => + page.evaluate(() => (window as unknown as { cursorActions: string[] }).cursorActions), + ) + .toEqual(expect.arrayContaining(["click", "type"])); const layer = (await preview.getByTestId("agent-cursor-layer").boundingBox())!; await expect .poll(async () => { diff --git a/apps/web/e2e/artifact-preview.spec.ts b/apps/web/e2e/artifact-preview.spec.ts index 8fa54ce..e39ae03 100644 --- a/apps/web/e2e/artifact-preview.spec.ts +++ b/apps/web/e2e/artifact-preview.spec.ts @@ -12,14 +12,12 @@ test("agent-attached files appear as downloadable cards", async ({ page }, testI await composer.fill("write notes/result.txt and attach it to the thread"); await page.keyboard.press("Enter"); - // Scope to ArtifactFileCard text (name + mime); sidebar bot status can also - // include the prompt path notes/result.txt once status syncs immediately. - const fileCard = page.getByRole("button", { name: /result\.txt text\/plain/ }); + const fileCard = page.getByRole("button", { name: "Preview result.txt" }); await expect(fileCard).toBeVisible({ timeout: 30_000 }); await captureScreenshot(page, testInfo, "current-file-card"); const downloadPromise = page.waitForEvent("download"); - await fileCard.click(); + await page.getByRole("button", { name: "Download result.txt" }).click(); const download = await downloadPromise; expect(download.suggestedFilename()).toBe("result.txt"); }); diff --git a/apps/web/e2e/bot-crud.spec.ts b/apps/web/e2e/bot-crud.spec.ts index e0581de..f26d732 100644 --- a/apps/web/e2e/bot-crud.spec.ts +++ b/apps/web/e2e/bot-crud.spec.ts @@ -114,12 +114,12 @@ test("bot creation, editing, and deletion persist", async ({ page }, testInfo) = await captureScreenshot(page, testInfo, "27a-bot-settings-model"); await page.getByRole("button", { name: "Show computer" }).click(); const sidePanel = page.getByTestId("side-panel"); - await expect(sidePanel).toHaveAttribute("data-panel", "computer"); + await expect(sidePanel).toHaveAttribute("data-panel", /^(computer|browser)$/); await expect(page.getByRole("button", { name: "Show settings" })).toBeVisible(); // Overlay may flash during boot or never appear (already ready/asleep/stopped). Assert panel // chrome, then wait until any overlay has cleared — avoid Locator.or() strict-mode multi-hits. const bootOverlay = page.getByText(/Booting up .* computer/); - await expect(sidePanel.getByTestId("computer-preview")).toBeVisible(); + await expect(sidePanel.getByTestId("oo-computer-browser-viewport")).toBeVisible(); await expect(bootOverlay).toBeHidden(); await expect(sidePanel.getByText("Teach a task")).toHaveCount(0); await expect(sidePanel.getByTestId("teach-start-button")).toHaveCount(0); diff --git a/apps/web/e2e/computer-screen-error.spec.ts b/apps/web/e2e/computer-screen-error.spec.ts index 94f3dc1..5fd2aff 100644 --- a/apps/web/e2e/computer-screen-error.spec.ts +++ b/apps/web/e2e/computer-screen-error.spec.ts @@ -39,9 +39,9 @@ test("screen connection failures stay visible and can be retried", async ({ page ); await page.getByTitle("Agent computer").click(); - const preview = page.getByTestId("computer-preview"); + const preview = page.getByTestId("oo-computer-browser-viewport"); await expect(preview.getByRole("alert")).toContainText("temporarily busy"); - await expect(preview.getByTestId("computer-preview-open")).toHaveCount(0); + await expect(page.getByTestId("computer-open")).toHaveCount(0); await captureScreenshot(page, testInfo, "computer-screen-connection-error"); failScreen = false; @@ -50,14 +50,14 @@ test("screen connection failures stay visible and can be retried", async ({ page await expect(preview.getByRole("alert")).toHaveCount(0); failScreen = true; - await preview.hover(); - await preview.getByTestId("computer-preview-open").click(); + await page.getByTestId("computer-open").click(); await expect(page.getByRole("button", { name: "Close computer" })).toBeVisible(); - await expect(page.getByRole("alert")).toContainText("temporarily busy"); + const fullScreen = page.getByTestId("computer-viewport"); + await expect(fullScreen.getByRole("alert")).toContainText("temporarily busy"); await captureScreenshot(page, testInfo, "computer-full-screen-connection-error"); failScreen = false; - await page.getByRole("button", { name: "Retry screen" }).click(); + await fullScreen.getByRole("button", { name: "Retry screen" }).click(); await expect(page.locator('iframe[title="Bot screen"]')).toHaveAttribute("src", screenUrl); await expect(page.getByRole("alert")).toHaveCount(0); }); diff --git a/apps/web/e2e/computer-update.spec.ts b/apps/web/e2e/computer-update.spec.ts index bd3d2c1..c07e3e9 100644 --- a/apps/web/e2e/computer-update.spec.ts +++ b/apps/web/e2e/computer-update.spec.ts @@ -59,9 +59,8 @@ test("computer maintenance shows durable background progress and failure recover }); }); await page.getByTitle("Agent computer").click(); - await expect(page.getByTestId("computer-preview")).toBeVisible(); - await page.getByTestId("computer-preview").hover(); - await page.getByTestId("computer-preview-open").click(); + await expect(page.getByTestId("oo-computer-browser-viewport")).toBeVisible(); + await page.getByTestId("computer-open").click(); await page.getByTestId("computer-more-button").click(); await page.getByRole("menuitem", { name: "Update computer", exact: true }).click(); const dialog = page.getByTestId("computer-update-dialog"); diff --git a/apps/web/e2e/first-run-onboarding-ux.spec.ts b/apps/web/e2e/first-run-onboarding-ux.spec.ts index d368d08..37245d1 100644 --- a/apps/web/e2e/first-run-onboarding-ux.spec.ts +++ b/apps/web/e2e/first-run-onboarding-ux.spec.ts @@ -94,10 +94,11 @@ test("main conversation opens its lead's computer", async ({ page }, testInfo) = await signup(page, `main-computer-${stamp}@rakazo.test`, "password12", "Screen"); await completeOnboarding(page, undefined, { mainConversation: true }); await expect(page).toHaveURL(/\/app\/g\//); + // The lead's computer opens beside the main conversation without leaving it. await page.getByTitle("Agent computer").click(); - await expect(page).not.toHaveURL(/\/app\/g\//); + await expect(page).toHaveURL(/\/app\/g\//); const sidePanel = page.getByTestId("side-panel"); await expect(sidePanel).toHaveAttribute("data-panel", "computer"); - await expect(sidePanel.getByTestId("computer-preview")).toBeVisible(); + await expect(sidePanel.getByTestId("oo-computer-browser-viewport")).toBeVisible(); await captureScreenshot(page, testInfo, "main-conversation-computer"); }); diff --git a/apps/web/e2e/golden.spec.ts b/apps/web/e2e/golden.spec.ts index 8487097..1e4341b 100644 --- a/apps/web/e2e/golden.spec.ts +++ b/apps/web/e2e/golden.spec.ts @@ -80,8 +80,7 @@ test("takeover, routine, plugins, and export are reachable", async ({ page }, te expect(mainBox).not.toBeNull(); expect(panelBox).not.toBeNull(); expect((mainBox?.x ?? 0) + (mainBox?.width ?? 0)).toBeLessThanOrEqual(panelBox?.x ?? 0); - await sidePanel.getByTestId("computer-preview").hover(); - await sidePanel.getByTestId("computer-preview-open").click(); + await sidePanel.getByTestId("computer-open").click(); await expect(page.getByRole("button", { name: "Close computer" })).toBeVisible(); await expect(page.getByRole("button", { name: "Skip", exact: true }).last()).toBeVisible(); await expect(page.getByRole("button", { name: "I’m done", exact: true }).last()).toBeVisible(); @@ -107,8 +106,7 @@ test("takeover, routine, plugins, and export are reachable", async ({ page }, te } await page.getByTitle("Agent computer").click(); await expect(sidePanel).toHaveAttribute("data-panel", "computer"); - await sidePanel.getByTestId("computer-preview").hover(); - const openComputer = sidePanel.getByTestId("computer-preview-open"); + const openComputer = sidePanel.getByTestId("computer-open"); await expect(openComputer).toBeVisible({ timeout: 30_000 }); await openComputer.click(); await expect(page.getByRole("button", { name: "Close computer" })).toBeVisible(); @@ -140,6 +138,7 @@ test("takeover, routine, plugins, and export are reachable", async ({ page }, te ); await page.getByTitle("Agent computer").click(); await routinesListed; + await sidePanel.locator('[data-tab="overview"]').click(); await expect( sidePanel.getByTestId("routine-list-row").getByText("Monday briefing"), ).toBeVisible(); @@ -316,7 +315,7 @@ test("takeover, routine, plugins, and export are reachable", async ({ page }, te await page.getByTitle("Agent computer").click(); } await expect(sidePanel).toHaveAttribute("data-panel", "computer"); - const gear = page.getByRole("button", { name: "Show settings" }); + const gear = page.getByRole("button", { name: "Show settings" }).first(); await gear.click(); const downloadPromise = page.waitForEvent("download"); await page.getByRole("button", { name: "Export" }).click(); diff --git a/apps/web/e2e/helpers.ts b/apps/web/e2e/helpers.ts index 4769201..3816781 100644 --- a/apps/web/e2e/helpers.ts +++ b/apps/web/e2e/helpers.ts @@ -61,8 +61,12 @@ export async function createTestRoutine( }); } -export async function openAgentComputerPanel(page: Page) { - await page.getByTitle("Agent computer").click(); +/** Opens the side panel on the agent's overview, where its routines are listed. */ +export async function openAgentOverview(page: Page, title = "Agent computer") { + const overview = page.getByTestId("side-panel").locator('[data-tab="overview"]'); + // The header button toggles the panel, so only press it while the panel is closed. + if (!(await overview.isVisible())) await page.getByTitle(title).click(); + await overview.click(); } export async function rpc(page: Page, procedure: string, body: unknown): Promise { diff --git a/apps/web/e2e/heute-brief.spec.ts b/apps/web/e2e/heute-brief.spec.ts index 4caac78..2035f54 100644 --- a/apps/web/e2e/heute-brief.spec.ts +++ b/apps/web/e2e/heute-brief.spec.ts @@ -1,7 +1,14 @@ import type { Page } from "@playwright/test"; import { expect, test } from "@playwright/test"; import type { CoachRhythm, CoachToday, Goal, Habit, Routine } from "@rakazo/contracts"; -import { captureScreenshot, completeOnboarding, openMainBotThread, rpc, signup } from "./helpers"; +import { + captureScreenshot, + completeOnboarding, + openAgentOverview, + openMainBotThread, + rpc, + signup, +} from "./helpers"; /** The nav button reads "Today" or "Today, N need you". */ function openHeute(page: Page) { @@ -142,7 +149,7 @@ test("Heute shows the coach's brief, habits and goals, checks habits off and set // The main bot's routine list names the check-ins by their key, in the UI language. await openMainBotThread(page); - await page.getByTitle("Agent computer").click(); + await openAgentOverview(page); await expect(page.getByText("Morning brief", { exact: true })).toBeVisible(); await expect(page.getByText("Weekly review", { exact: true })).toBeVisible(); }); diff --git a/apps/web/e2e/in-app-browser.spec.ts b/apps/web/e2e/in-app-browser.spec.ts index b3df287..b2d62fb 100644 --- a/apps/web/e2e/in-app-browser.spec.ts +++ b/apps/web/e2e/in-app-browser.spec.ts @@ -15,7 +15,7 @@ test("the in-app browser previews a page and follows it back to the browser", as }), ); - await page.getByRole("button", { name: "Browser" }).click(); + await page.getByTitle("Agent computer").click(); const browser = page.getByTestId("in-app-browser"); await browser.getByTestId("in-app-browser-mode-local").click(); const address = browser.getByRole("textbox", { name: "Address" }); @@ -42,7 +42,7 @@ test("the in-app browser previews a page and follows it back to the browser", as await page.getByTestId("side-panel").getByRole("button", { name: "Close panel" }).click(); await expect(browser).toHaveCount(0); // The last address comes back when the browser reopens. - await page.getByRole("button", { name: "Browser" }).click(); + await page.getByTitle("Agent computer").click(); await page.getByTestId("in-app-browser-mode-local").click(); await expect(page.getByTestId("in-app-browser-frame")).toHaveAttribute( "src", @@ -113,7 +113,7 @@ test("on desktop the page is a native view glued to the panel", async ({ page }) () => (window as unknown as { __browserCalls: Array<[string, unknown?]> }).__browserCalls, ); - await page.getByRole("button", { name: "Browser" }).click(); + await page.getByTitle("Agent computer").click(); const browser = page.getByTestId("in-app-browser"); await browser.getByTestId("in-app-browser-mode-local").click(); await browser.getByRole("textbox", { name: "Address" }).fill("localhost:5173"); diff --git a/apps/web/e2e/media-pipeline.spec.ts b/apps/web/e2e/media-pipeline.spec.ts index eefc3a4..03dd28d 100644 --- a/apps/web/e2e/media-pipeline.spec.ts +++ b/apps/web/e2e/media-pipeline.spec.ts @@ -73,7 +73,7 @@ test("an image in the main conversation opens and downloads from the artifact ro await expect(thumbnail).toHaveAttribute("src", /^\/api\/artifacts\/[^/?]+$/); await thumbnail.click(); - const dialog = page.getByRole("dialog", { name: "pixel.png" }); + const dialog = page.getByRole("dialog", { name: "Pixel" }); const download = dialog.getByRole("link", { name: "Download pixel.png" }); await expect(download).toHaveAttribute("href", /\/api\/artifacts\/[^/?]+\?download=1$/); const saved = page.waitForEvent("download"); diff --git a/apps/web/e2e/message-hover-actions.spec.ts b/apps/web/e2e/message-hover-actions.spec.ts index 37c49f5..e4be06d 100644 --- a/apps/web/e2e/message-hover-actions.spec.ts +++ b/apps/web/e2e/message-hover-actions.spec.ts @@ -92,7 +92,13 @@ test("message hover shows below-bubble actions; reply links to parent", async ({ ); const botText = page.getByText(/i handled: hover-bot-/); await expect(transcript.getByText(/i handled: hover-bot-/)).toBeVisible({ timeout: 20_000 }); - const botRow = transcript.locator(`[data-message-id]`).filter({ has: botText }).first(); + // Pin the row by id: once the run settles, long replies fold their details away. + const botMessageId = await transcript + .locator(`[data-message-id]`) + .filter({ has: botText }) + .first() + .getAttribute("data-message-id"); + const botRow = transcript.locator(`[data-message-id="${botMessageId}"]`); await expect(botRow).toBeVisible(); await expectRailAtRest(page, botRow); await captureScreenshot(page, testInfo, "message-actions-rest-desktop"); diff --git a/apps/web/e2e/routine-crud.spec.ts b/apps/web/e2e/routine-crud.spec.ts index e4c138b..f73451c 100644 --- a/apps/web/e2e/routine-crud.spec.ts +++ b/apps/web/e2e/routine-crud.spec.ts @@ -6,7 +6,7 @@ import { completeOnboarding, createNamedBot, createTestRoutine, - openAgentComputerPanel, + openAgentOverview, rpc, signup, } from "./helpers"; @@ -33,7 +33,7 @@ test("routine active switch keeps its thumb inside the track", async ({ page }, await completeOnboarding(page); const botId = activeBotId(page); await createTestRoutine(page, botId, { name: "Toggle styling" }); - await openAgentComputerPanel(page); + await openAgentOverview(page); await page.getByRole("button", { name: /Toggle styling/ }).click(); const toggle = page.getByRole("switch", { name: "Active" }); @@ -89,7 +89,7 @@ test("routine editing updates in place, preserves timezone, and deletion persist expect(created.nextRunAt).not.toBeNull(); expect(localSchedule(created.nextRunAt!, created.timezone)).toMatchObject({ hour: 9, minute: 0 }); await page.reload(); - await page.getByTitle("Agent computer").click(); + await openAgentOverview(page); await page.getByRole("button", { name: /Tokyo check-in/ }).click(); await page.locator("label:has-text('Name') input").fill("Weekday check-in"); @@ -133,7 +133,7 @@ test("routine editing updates in place, preserves timezone, and deletion persist expect(await rpc(page, "routines/list", { botId })).toEqual([]); await page.reload(); - await page.getByTitle("Agent computer").click(); + await openAgentOverview(page); await expect(updatedButton).toHaveCount(0); }); @@ -172,7 +172,7 @@ test("a successful routine create is not reported as failed when refresh fails", name: "Persisted routine", prompt: "Run once each morning", }); - await openAgentComputerPanel(page); + await openAgentOverview(page); await page.getByRole("button", { name: /Persisted routine/ }).click(); await page .locator("label:has-text('Instruction') textarea") @@ -240,7 +240,7 @@ test("switching bots while a routine save is pending does not reopen stale state ]); await page.reload(); - await page.getByTitle("Agent computer").click(); + await openAgentOverview(page); await page.getByRole("button", { name: /First routine/ }).click(); await page.locator("label:has-text('Name') input").fill("First routine updated"); @@ -280,7 +280,7 @@ test("switching bots while a routine save is pending does not reopen stale state await expect(page.getByTestId("side-panel")).toHaveAttribute("data-panel", "closed"); await expect(page.getByRole("alert")).toHaveCount(0); - await page.getByTitle("Agent computer").click(); + await openAgentOverview(page); await expect(page.getByRole("button", { name: /Second routine/ })).toHaveCount(1); await expect(page.getByRole("button", { name: /First routine/ })).toHaveCount(0); diff --git a/apps/web/e2e/routine-execution.spec.ts b/apps/web/e2e/routine-execution.spec.ts index fa3dcf5..18c5e92 100644 --- a/apps/web/e2e/routine-execution.spec.ts +++ b/apps/web/e2e/routine-execution.spec.ts @@ -5,7 +5,7 @@ import { captureScreenshot, completeOnboarding, createTestRoutine, - openAgentComputerPanel, + openAgentOverview, rpc, signup, } from "./helpers"; @@ -38,7 +38,7 @@ test("Slack message trigger uses the mounted messaging provider and persists", a githubEnabled: false, messageProvider: "slack", }); - await openAgentComputerPanel(page); + await openAgentOverview(page); const panel = page.getByTestId("side-panel"); await expect(panel.getByText("Slack message", { exact: true })).toBeVisible(); const [routine] = await rpc(page, "routines/list", { botId }); @@ -72,7 +72,7 @@ test("GitHub event trigger exposes signed delivery settings and persists", async githubEnabled: true, messageProvider: null, }); - await openAgentComputerPanel(page); + await openAgentOverview(page); await page.getByRole("button", { name: /Review repository events/ }).click(); await expect( page.getByTestId("side-panel").getByText("Git event", { exact: true }), @@ -111,7 +111,7 @@ test("Korean webhook routine keeps technical field labels in English", async ({ crons: [], webhookEnabled: true, }); - await page.getByTitle("Agent 컴퓨터").click(); + await openAgentOverview(page, "Agent 컴퓨터"); await page.getByRole("button", { name: /한국어 웹훅 확인/ }).click(); await expect(page.getByText("웹훅이 실행될 때", { exact: true })).toBeVisible(); @@ -127,7 +127,7 @@ test("routine test-run completes and survives reload", async ({ page }, testInfo await completeOnboarding(page); const botId = activeBotId(page); - await openAgentComputerPanel(page); + await openAgentOverview(page); await expect(page.getByRole("button", { name: "Test run" })).toHaveCount(0); await createTestRoutine(page, botId, { name: "Daily verification", @@ -135,7 +135,7 @@ test("routine test-run completes and survives reload", async ({ page }, testInfo crons: ["0 9 * * 1-5"], }); await page.reload(); - await openAgentComputerPanel(page); + await openAgentOverview(page); const routine = page.getByRole("button", { name: /Daily verification/ }); await expect(routine).toContainText("Weekdays at 9:00 AM"); await captureScreenshot(page, testInfo, "33-routine-scheduled"); @@ -148,7 +148,7 @@ test("routine test-run completes and survives reload", async ({ page }, testInfo await page.reload(); await expect(page.getByText(/routine-run-now-ok/i).first()).toBeVisible(); - await page.getByTitle("Agent computer").click(); + await openAgentOverview(page); await expect(page.getByRole("button", { name: /Daily verification/ })).toContainText( "Weekdays at 9:00 AM", ); diff --git a/apps/web/e2e/routine-list-toggle.spec.ts b/apps/web/e2e/routine-list-toggle.spec.ts index 244071c..66d256b 100644 --- a/apps/web/e2e/routine-list-toggle.spec.ts +++ b/apps/web/e2e/routine-list-toggle.spec.ts @@ -4,7 +4,7 @@ import { activeBotId, completeOnboarding, createTestRoutine, - openAgentComputerPanel, + openAgentOverview, rpc, signup, } from "./helpers"; @@ -17,14 +17,14 @@ test("routines section is hidden until the Oo has one, then list toggle persists await completeOnboarding(page); const botId = activeBotId(page); - await openAgentComputerPanel(page); + await openAgentOverview(page); const panel = page.getByTestId("side-panel"); await expect(panel.getByText("Routines", { exact: true })).toHaveCount(0); await expect(page.getByTestId("routine-create-button")).toHaveCount(0); await createTestRoutine(page, botId, { name: "Morning check-in" }); await page.reload(); - await openAgentComputerPanel(page); + await openAgentOverview(page); await expect(panel.getByText("Routines", { exact: true })).toBeVisible(); await expect(page.getByRole("button", { name: /Morning check-in/ })).toBeVisible(); diff --git a/apps/web/e2e/teach-task.spec.ts b/apps/web/e2e/teach-task.spec.ts index 835d31c..5436989 100644 --- a/apps/web/e2e/teach-task.spec.ts +++ b/apps/web/e2e/teach-task.spec.ts @@ -16,18 +16,16 @@ test("teach a task records interaction and saves a draft", async ({ page }, test await expect(sidePanel.getByRole("button", { name: "Update computer" })).toHaveCount(0); await expect(sidePanel.getByRole("button", { name: "Take control" })).toHaveCount(0); await expect(sidePanel.getByTestId("computer-more-button")).toHaveCount(0); - await expect(sidePanel.getByTestId("computer-preview")).toBeVisible(); + await expect(sidePanel.getByTestId("oo-computer-browser-viewport")).toBeVisible(); // Showing the panel never boots the computer; its Open action is visible without hover. - const openButton = sidePanel.getByTestId("computer-preview-open"); + const openButton = sidePanel.getByTestId("computer-open"); await expect(openButton).toBeVisible(); const botId = activeBotId(page); expect((await rpc<{ state: string }>(page, "computer/status", { botId })).state).toBe("stopped"); await captureScreenshot(page, testInfo, "teach-sidepanel-simple"); - const preview = sidePanel.getByTestId("computer-preview"); - await preview.hover(); + const preview = sidePanel.getByTestId("oo-computer-browser-viewport"); await expect(openButton).toBeVisible(); - await expect(openButton.getByText("Open", { exact: true })).toBeVisible(); await captureScreenshot(page, testInfo, "teach-sidepanel-open-hover"); await openButton.click(); diff --git a/apps/web/e2e/teaching-composer.spec.ts b/apps/web/e2e/teaching-composer.spec.ts index a6f0f27..a49f007 100644 --- a/apps/web/e2e/teaching-composer.spec.ts +++ b/apps/web/e2e/teaching-composer.spec.ts @@ -10,7 +10,7 @@ test("blocks the composer while a skill is being taught", async ({ page }) => { await page.getByTitle("Agent computer").click(); const sidePanel = page.getByTestId("side-panel"); await expect(sidePanel).toHaveAttribute("data-panel", "computer"); - const openButton = sidePanel.getByTestId("computer-preview-open"); + const openButton = sidePanel.getByTestId("computer-open"); await expect(openButton).toBeVisible(); await openButton.click(); const chrome = page.getByTestId("computer-chrome"); diff --git a/apps/web/e2e/team-computer.spec.ts b/apps/web/e2e/team-computer.spec.ts index f0413f8..7ad40df 100644 --- a/apps/web/e2e/team-computer.spec.ts +++ b/apps/web/e2e/team-computer.spec.ts @@ -51,7 +51,7 @@ test("Team Computer gives bots a home folder plus shared space while Private sta const privateId = await createBot(page, "Private Writer", "dedicated"); await openComputerPanel(page); - await expect(page.getByText(/Private Writer['’]s screen/).last()).toBeVisible(); + await expect(page.getByText(/Private Writer['’]s browser/).last()).toBeVisible(); await captureScreenshot(page, testInfo, "43-private-computer"); await expect(readFileResponse(page, privateId, "notes/result.txt")).resolves.toMatchObject({ ok: false, @@ -94,8 +94,7 @@ test("user control leaves another Team bot's screen available", async ({ page }, // The sidebar entry for the main bot opens the main conversation; its computer is in the 1:1. await openMainBotThread(page); await page.getByTitle("Agent computer").click(); - await page.getByTestId("computer-preview").hover(); - await page.getByTestId("computer-preview-open").click(); + await page.getByTestId("computer-open").click(); await expect(page.getByRole("button", { name: "Close computer" })).toBeVisible(); await page.getByRole("button", { name: "Close computer" }).click(); await expect(page.getByRole("button", { name: "Close computer" })).toHaveCount(0); @@ -132,8 +131,7 @@ test("a failed control release keeps the computer open for retry", async ({ page await signup(page, `team-release-${Date.now()}@rakazo.test`, "password12", "Team Release"); await completeOnboarding(page); await page.getByTitle("Agent computer").click(); - await page.getByTestId("computer-preview").hover(); - await page.getByTestId("computer-preview-open").click(); + await page.getByTestId("computer-open").click(); const chrome = page.getByTestId("computer-chrome"); const release = chrome.getByRole("button", { name: "Release", exact: true }); await expect(release).toBeVisible(); @@ -192,10 +190,8 @@ test("an active Team bot must be stopped before user takeover", async ({ page }, await page.getByTitle("Agent computer").click(); const sidePanel = page.getByTestId("side-panel"); await expect(sidePanel.getByRole("button", { name: /Take control/i })).toHaveCount(0); - await page.getByTestId("computer-preview").hover(); - const openBusy = sidePanel.getByTestId("computer-preview-open"); + const openBusy = sidePanel.getByTestId("computer-open"); await expect(openBusy).toBeVisible(); - await expect(openBusy.getByText("Open", { exact: true })).toBeVisible(); await openBusy.click(); const chrome = page.getByTestId("computer-chrome"); await expect(page.getByRole("button", { name: "Close computer" })).toBeVisible(); @@ -221,8 +217,7 @@ test("an active Team bot must be stopped before user takeover", async ({ page }, .toBeNull(); // After stop, Open via hover is the takeover path (no Take control button). - await page.getByTestId("computer-preview").hover(); - await page.getByTestId("computer-preview-open").click(); + await page.getByTestId("computer-open").click(); await expect(page.getByRole("button", { name: "Close computer" })).toBeVisible(); await expect(chrome.getByText("You have control", { exact: true })).toBeVisible(); await expect(chrome.getByRole("button", { name: /Take control/i })).toHaveCount(0); @@ -287,8 +282,8 @@ async function expectComposerReady(page: Page) { async function openComputerPanel(page: Page) { await page.getByTitle("Agent computer").click(); - await expect(page.getByTestId("computer-preview")).toBeVisible(); - await expect(page.getByTestId("computer-preview-open")).toHaveCount(1); + await expect(page.getByTestId("oo-computer-browser-viewport")).toBeVisible(); + await expect(page.getByTestId("computer-open")).toHaveCount(1); } async function sendAndWait(page: Page, botId: string, text: string) { diff --git a/apps/web/e2e/voice.spec.ts b/apps/web/e2e/voice.spec.ts index 9005470..9aff10f 100644 --- a/apps/web/e2e/voice.spec.ts +++ b/apps/web/e2e/voice.spec.ts @@ -7,7 +7,10 @@ async function turnOnSpeaking(page: Page) { await expect(voice).toBeVisible(); await expect(voice.getByRole("button", { name: "Other voice providers" })).toHaveCount(0); await expect(voice.getByLabel("API key", { exact: true })).toHaveCount(0); - await voice.getByRole("switch", { name: "Speaking on or off" }).click(); + // The app turns the local voice on by itself the first time it starts. + const speaking = voice.getByRole("switch", { name: "Speaking on or off" }); + if ((await speaking.getAttribute("aria-checked")) !== "true") await speaking.click(); + await expect(speaking).toHaveAttribute("aria-checked", "true"); await expect(voice.getByTestId("voice-carousel")).toBeVisible(); return voice; } @@ -25,11 +28,6 @@ test("voice settings pick a voice from the carousel, speak a reply, and open a c ).toHaveCount(1); await captureScreenshot(page, testInfo, "voice-composer"); - const preparedOff = await rpc<{ ready: boolean }>(page, "voice/prepare", { - text: "Hello there.", - }); - expect(preparedOff.ready).toBe(false); - const voice = await turnOnSpeaking(page); const cards = voice.getByTestId("voice-card"); // English UI lists only the English voices of the local server. diff --git a/apps/web/src/components/AskCard.test.tsx b/apps/web/src/components/AskCard.test.tsx index de9a774..aff88de 100644 --- a/apps/web/src/components/AskCard.test.tsx +++ b/apps/web/src/components/AskCard.test.tsx @@ -47,12 +47,12 @@ it("renders primary approval button text with text-primary-foreground for contra kind: "ask", text: "**Review before shell**\n\nThis run would push commits to a remote.", detail: "git push", - status: "open", + status: "pending", approvalEffectId: "effect-1", actions: [ - { id: "allow", label: "Allow once", style: "primary" }, - { id: "always", label: "Always allow this tool", style: "secondary" }, - { id: "deny", label: "Deny", style: "danger" }, + { id: "allow", label: "Allow once" }, + { id: "always", label: "Always allow this tool" }, + { id: "deny", label: "Deny" }, ], }; @@ -95,7 +95,7 @@ it("uses text-foreground for non-approval action buttons", async () => { const block: AskBlock = { kind: "ask", text: "Which branch should we deploy?", - status: "open", + status: "pending", actions: [ { id: "main", label: "main" }, { id: "staging", label: "staging" }, diff --git a/apps/web/src/components/browser/InAppBrowser.tsx b/apps/web/src/components/browser/InAppBrowser.tsx index 77a9f4f..07fcd86 100644 --- a/apps/web/src/components/browser/InAppBrowser.tsx +++ b/apps/web/src/components/browser/InAppBrowser.tsx @@ -17,8 +17,8 @@ export function InAppBrowser({ computerScreenError, sandboxProvider, booting, - onBoot, onRefreshScreen, + onOpenComputer, hideCloseButton = false, }: { onClose: () => void; @@ -28,8 +28,8 @@ export function InAppBrowser({ computerScreenError: React.ReactNode; sandboxProvider: string | undefined; booting: boolean; - onBoot: () => Promise; onRefreshScreen: () => Promise; + onOpenComputer?: () => void; hideCloseButton?: boolean; }) { const { t } = useLingui(); @@ -91,8 +91,8 @@ export function InAppBrowser({ computerScreenError={computerScreenError} sandboxProvider={sandboxProvider} booting={booting} - onBoot={onBoot} onRefreshScreen={onRefreshScreen} + onOpenComputer={onOpenComputer} /> ) : (
diff --git a/apps/web/src/components/browser/OoComputerBrowser.tsx b/apps/web/src/components/browser/OoComputerBrowser.tsx index 24043dd..4b4155b 100644 --- a/apps/web/src/components/browser/OoComputerBrowser.tsx +++ b/apps/web/src/components/browser/OoComputerBrowser.tsx @@ -1,7 +1,7 @@ import { Trans, useLingui } from "@lingui/react/macro"; import type { Bot, ComputerStatus } from "@rakazo/contracts"; import { Button, cn, Input } from "@rakazo/ui-web"; -import { ArrowLeft, ArrowRight, ExternalLink, Loader2, RotateCw } from "lucide-react"; +import { ArrowLeft, ArrowRight, ExternalLink, Loader2, Maximize2, RotateCw } from "lucide-react"; import { type FormEvent, useCallback, useEffect, useRef, useState } from "react"; import { agentPointerFeed } from "../../lib/agent-pointer"; import { normalizeBrowserUrl } from "../../lib/browser-url"; @@ -34,8 +34,8 @@ export function OoComputerBrowser({ computerScreenError, sandboxProvider, booting, - onBoot, onRefreshScreen, + onOpenComputer, }: { active: Bot; computer: ComputerStatus | null; @@ -43,8 +43,9 @@ export function OoComputerBrowser({ computerScreenError: React.ReactNode; sandboxProvider: string | undefined; booting: boolean; - onBoot: () => Promise; onRefreshScreen: () => Promise; + /** Opens the full computer window, where the user can take control or teach. */ + onOpenComputer?: () => void; }) { const { t } = useLingui(); const [chrome, setChrome] = useState(EMPTY_CHROME); @@ -87,11 +88,13 @@ export function OoComputerBrowser({ }; }, [active.id, computer?.state, refreshChrome]); + const refreshScreen = useRef(onRefreshScreen); + refreshScreen.current = onRefreshScreen; + // Showing the browser never starts a stopped computer; it only reconnects a running screen. useEffect(() => { - if (computer?.state === "running" && (embeddedScreenUrl || computer?.kind === "desktop")) - return; - void onBoot().catch(() => undefined); - }, [active.id, computer?.kind, computer?.state, embeddedScreenUrl, onBoot]); + if (computer?.state !== "running" || embeddedScreenUrl || computer?.kind === "desktop") return; + void refreshScreen.current().catch(() => undefined); + }, [active.id, computer?.kind, computer?.state, embeddedScreenUrl]); const runNav = useCallback( async (task: () => Promise) => { @@ -215,6 +218,18 @@ export function OoComputerBrowser({ > + {onOpenComputer && !unavailable && !computerScreenError ? ( + + ) : null}
undefined); } + async function openComputer() { + const targetBot = active ?? coordinatorBot; + if (!targetBot) return; + const needsTakeover = !userHoldsComputerControl(computer, targetBot.id); + const blocked = computerTakeoverBlocked(computer, snapshot?.run?.status); + try { + await bootComputer({ + takeControl: needsTakeover && !blocked, + overlay: (needsTakeover && !blocked) || computer?.state !== "running", + force: computer?.state !== "running", + }); + setComputerOpen(true); + } catch { + // computerError already set in bootComputer + } + } + async function bootComputer({ takeControl, overlay, @@ -4609,18 +4626,13 @@ export function ShellPage() { computerScreenError={computerScreenError} sandboxProvider={bootstrapMe?.sandboxProvider} booting={booting} - onBoot={async () => { - const target = active ?? coordinatorBot; - if (!target) return; - await bootComputer({ takeControl: false, overlay: false, force: false }); - await refreshComputerScreen(target.id); - }} onRefreshScreen={async () => { const target = active ?? coordinatorBot; if (!target) return; await refreshComputerScreen(target.id); await refreshThread(target.id).catch(() => undefined); }} + onOpenComputer={() => void openComputer()} />
) : ( diff --git a/apps/web/src/pages/shell/composer-task-pill.test.tsx b/apps/web/src/pages/shell/composer-task-pill.test.tsx index 125d018..a0c0ddb 100644 --- a/apps/web/src/pages/shell/composer-task-pill.test.tsx +++ b/apps/web/src/pages/shell/composer-task-pill.test.tsx @@ -6,6 +6,31 @@ import type { ThreadMessage } from "@rakazo/contracts"; import { act } from "react"; import { createRoot } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +function formatTemplate(parts: TemplateStringsArray, ...values: unknown[]) { + return parts.reduce( + (text, part, index) => text + part + (index < values.length ? values[index] : ""), + "", + ); +} + +vi.mock("@lingui/core/macro", () => ({ + t: formatTemplate, + msg: (parts: TemplateStringsArray, ...values: unknown[]) => ({ + id: parts.join(""), + message: formatTemplate(parts, ...values), + }), +})); + +vi.mock("@lingui/react/macro", () => { + return { + useLingui: () => ({ t: formatTemplate }), + Trans: ({ children }: { children: React.ReactNode }) => children, + Plural: ({ value, one, other }: { value: number; one: string; other: string }) => + (value === 1 ? one : other).replace("#", String(value)), + }; +}); + import { ComposerTaskPill, extractActivePlanState, PlanCard } from "./message-cards"; // @ts-expect-error React act environment flag diff --git a/apps/web/src/pages/shell/composer.tsx b/apps/web/src/pages/shell/composer.tsx index b7906b2..f6e1c7b 100644 --- a/apps/web/src/pages/shell/composer.tsx +++ b/apps/web/src/pages/shell/composer.tsx @@ -338,7 +338,10 @@ export const Composer = memo(function Composer({ const slashSkillOptions = useMemo(() => { if (slashQuery === null) return []; const query = slashQuery.trim().toLowerCase(); - const skills = agentSkills ?? []; + // The user's own skills come first so built-in ones never crowd them out of the picker. + const skills = [...(agentSkills ?? [])].sort( + (a, b) => Number(a.source !== "user") - Number(b.source !== "user"), + ); return skills .filter((skill) => { if (!query) return true; @@ -603,7 +606,7 @@ export const Composer = memo(function Composer({ {showSlashPicker ? (
{slashSkillOptions.map((skill) => (