From de95cf1b91502d57efe88790964f1e6230995c66 Mon Sep 17 00:00:00 2001 From: cynarAI <155274210+cynarAI@users.noreply.github.com> Date: Thu, 22 Oct 2026 21:58:24 +0200 Subject: [PATCH] Fix red main: typecheck, lint, i18n, unit and web E2E (#222) * Fix red main: typecheck, lint, i18n and stale unit tests Direct pushes to main left typecheck, lint and 16 unit tests red. - Typecheck: AskCard test uses the current ask block shape, the sandbox fault test calls the two-argument desktop writeFile, removing a queued message notifies with the thread's latest event seq, and the replay fixture only reads ref from steps that carry one. - Lint: format and import order. - i18n: transcript status labels were German source strings; they are English now with German translations, catalogs re-extracted, and the mobile "Failed to remove message" string is translated. - Tests follow intended behavior changes: the time instruction now closes the system prompt, short chat turns start at low reasoning, owner-owned read-only files (git objects) pass the home check, and the desktop computer may read and write host files outside its home. The old desktop write-containment suite is replaced by a host file test. - The composer task pill test mocks the Lingui macros like other tests. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01TnT7eVHuaCzZKY5CnYzpr1 * Bump Expo patch releases so expo install --check passes Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01TnT7eVHuaCzZKY5CnYzpr1 * Bring web E2E back in line with the new side panel The side panel lost its computer tab, which left the full computer window (take control, teach, release) unreachable from the web app. The browser view now offers an "Open in full window" button, and opening the panel no longer boots a stopped computer. Also: the slash picker scrolls instead of growing under the header, user skills come first in it, the agent overview hides an empty routines block, the hover time hides again with collapsed details, and the light destructive token meets contrast on the sidebar. E2E specs follow the new panel layout, and the cursor and hover tests no longer race the UI. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01TnT7eVHuaCzZKY5CnYzpr1 --------- Co-authored-by: Claude --- apps/api/src/app.ts | 6 +- apps/api/src/thread-target.ts | 7 +- apps/mobile/lib/locales/de.ts | 1 + apps/mobile/lib/locales/ru.ts | 1 + apps/mobile/lib/locales/zh.ts | 1 + apps/mobile/package.json | 12 +- apps/web/e2e/adaptive-workspace.spec.ts | 2 +- apps/web/e2e/agent-cursor.spec.ts | 16 +- apps/web/e2e/artifact-preview.spec.ts | 6 +- apps/web/e2e/bot-crud.spec.ts | 4 +- apps/web/e2e/computer-screen-error.spec.ts | 12 +- apps/web/e2e/computer-update.spec.ts | 5 +- apps/web/e2e/first-run-onboarding-ux.spec.ts | 5 +- apps/web/e2e/golden.spec.ts | 9 +- apps/web/e2e/helpers.ts | 8 +- apps/web/e2e/heute-brief.spec.ts | 11 +- apps/web/e2e/in-app-browser.spec.ts | 6 +- apps/web/e2e/media-pipeline.spec.ts | 2 +- apps/web/e2e/message-hover-actions.spec.ts | 8 +- apps/web/e2e/routine-crud.spec.ts | 14 +- apps/web/e2e/routine-execution.spec.ts | 14 +- apps/web/e2e/routine-list-toggle.spec.ts | 6 +- apps/web/e2e/teach-task.spec.ts | 8 +- apps/web/e2e/teaching-composer.spec.ts | 2 +- apps/web/e2e/team-computer.spec.ts | 19 +- apps/web/e2e/voice.spec.ts | 10 +- apps/web/src/components/AskCard.test.tsx | 10 +- .../src/components/browser/InAppBrowser.tsx | 6 +- .../components/browser/OoComputerBrowser.tsx | 29 +- apps/web/src/lib/oo-audio-bus.ts | 6 +- apps/web/src/locales/de/messages.po | 96 +++-- apps/web/src/locales/en/messages.po | 88 +++- apps/web/src/locales/es/messages.po | 88 +++- apps/web/src/locales/hi/messages.po | 88 +++- apps/web/src/locales/ko/messages.po | 88 +++- apps/web/src/locales/pt-BR/messages.po | 88 +++- apps/web/src/locales/ru/messages.po | 88 +++- apps/web/src/locales/tr/messages.po | 88 +++- apps/web/src/locales/zh-CN/messages.po | 88 +++- apps/web/src/pages/Shell.tsx | 24 +- .../pages/shell/composer-task-pill.test.tsx | 25 ++ apps/web/src/pages/shell/composer.tsx | 7 +- .../pages/shell/computer-panel-preview.tsx | 109 ----- apps/web/src/pages/shell/project-overview.tsx | 49 +-- apps/web/src/pages/shell/transcript.tsx | 24 +- apps/web/src/pages/shell/use-shell-sounds.ts | 5 +- .../supervisor/src/home-ownership.test.ts | 4 +- .../desktop-sandbox-write-containment.test.ts | 400 ------------------ .../src/executor-instructions.test.ts | 4 +- .../adapters/src/host-aware-sandbox.test.ts | 16 + packages/adapters/src/kokoro-voice.ts | 1 + .../src/pi-runtime-thinking-level.test.ts | 17 +- packages/adapters/src/sandbox-faults.test.ts | 2 +- .../testkit/src/computer-replay-fixture.ts | 2 +- packages/ui-tokens/src/index.ts | 2 +- packages/ui-tokens/src/tokens.css | 2 +- pnpm-lock.yaml | 385 +++++++++-------- 57 files changed, 1117 insertions(+), 1007 deletions(-) delete mode 100644 apps/web/src/pages/shell/computer-panel-preview.tsx delete mode 100644 packages/adapters/src/desktop-sandbox-write-containment.test.ts 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) => (