feat: OpenMesh 基础平台与 MD/PDF 转换技能
Some checks failed
CI / pytest (push) Has been cancelled
CI / gui-unit (push) Has been cancelled
CI / gui-e2e (push) Has been cancelled

- 后端: coworker 智能体框架, WS API, 文件上传, 附件处理
- 前端: Open WebUI, 文件全量走 upload API (含 MD/TXT/JSON 等文本类)
- 技能: md-to-office (pandoc + wkhtmltopdf)
- 修复: 上传文件路径丢失, Agent 搜索浪费, 输出文件跑到 uploads/
- 打包: PyInstaller one-dir, 预打包 pandoc/wkhtmltopdf/chromium
This commit is contained in:
2026-09-13 23:41:04 +08:00
commit 6f402ffcee
638 changed files with 154534 additions and 0 deletions

9
surfaces/gui/.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
node_modules/
dist/
*.local
.DS_Store
# Playwright
/test-results/
/playwright-report/
/e2e/_dbg.png

52
surfaces/gui/README.md Normal file
View File

@@ -0,0 +1,52 @@
# coworker GUI (React + Tauri)
A thin client of the coworker server (OpenAI-compatible API + WS event/approval stream).
Same codebase runs in a browser (dev) and as the OpenWorker desktop app.
## First time: bootstrap the Python backend
A fresh checkout has no server to run — create the venv both flows below expect
(from the repo root):
```bash
bash packaging/setup_dev_env.sh # → .venv (server + aisuite)
```
## Run it (browser, two terminals)
1. **Start the server** (needs a model key, e.g. `OPENAI_API_KEY`, in the environment —
or add one later in the app's Settings), from the repo root:
```bash
./.venv/bin/openworker-server --cwd /path/to/your/project --port 8765
```
2. **Start the UI:**
```bash
cd surfaces/gui
npm install # first time
npm run dev # → http://localhost:5173
```
Open http://localhost:5173. The UI talks to `http://127.0.0.1:8765` (override with
`VITE_COWORKER_HTTP` / `VITE_COWORKER_WS`). Start the server before Vite so the
UI can read its per-launch token from `<state-dir>/sidecar-8765.token`; restart
Vite if the server is restarted.
## Run the desktop app from source
The Tauri shell wraps the same UI and supervises the Python server itself — no separate
terminal. It needs the Rust toolchain (`rustup`) plus the venv from the bootstrap step;
in dev it finds the server at `.venv/bin/openworker-server` automatically (a
packaged sidecar binary is only produced by the release scripts in `packaging/`).
```bash
cd surfaces/gui
npm install # first time
npm run tauri dev # builds the shell, launches the window, starts the server
```
## Tests
```bash
npx tsc --noEmit && npx vitest run # typecheck + unit
npx playwright test # hermetic e2e (mocked /v1 + WS, no Python needed)
```

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

View File

@@ -0,0 +1,43 @@
// LIVE smoke — API shape only (no model tokens). Hits the REAL sidecar's /v1/health and
// /v1/providers to catch integration drift between the GUI's expectations and the backend's
// responses. Skips cleanly when the backend is down, so it's safe to run anytime. No creds needed.
import { expect, test } from "@playwright/test";
import { backendFetch } from "./helpers";
async function backendUp(): Promise<boolean> {
try {
const res = await backendFetch("/v1/health");
return res.ok;
} catch {
return false;
}
}
test("health reports ok with the fields the GUI reads", async () => {
test.skip(!(await backendUp()), "backend not running on :8765");
const s = await (await backendFetch("/v1/health")).json();
expect(s.status).toBe("ok");
// The GUI's boot reads these three off /v1/health.
expect(s).toHaveProperty("model");
expect(s).toHaveProperty("default_workspace");
});
test("providers list has the shape the Settings pane expects", async () => {
test.skip(!(await backendUp()), "backend not running on :8765");
const providers = await (await backendFetch("/v1/providers")).json();
expect(Array.isArray(providers)).toBe(true);
expect(providers.length).toBeGreaterThan(0);
// Each descriptor carries what ManageTabs renders: name/title/needs_key/fields/configured.
for (const p of providers) {
expect(p).toMatchObject({
name: expect.any(String),
title: expect.any(String),
needs_key: expect.any(Boolean),
configured: expect.any(Boolean),
});
expect(Array.isArray(p.fields)).toBe(true);
}
// The core providers Rohit tested should be present.
const names = providers.map((p: any) => p.name);
expect(names).toEqual(expect.arrayContaining(["openai", "anthropic"]));
});

View File

@@ -0,0 +1,32 @@
import { test, expect } from "@playwright/test";
import { readFileSync } from "fs";
import { newestFile, scratchBaseIfReady, sendTask, startCoworkSession } from "./helpers";
// LIVE #1 — the approval gate. In the default "Ask for approval" mode a tool call must block on an
// in-transcript approval card; approving it lets execution proceed. (fib.md skips this via Full
// access.) Excluded from CI — run with `npm run e2e:live`.
test("live: a write blocks on an approval card, then completes once approved", async ({ page }) => {
const scratchBase = await scratchBaseIfReady();
test.skip(!scratchBase, "live backend not ready — start openworker-server and configure a model");
// Unique filename per run so the "doesn't exist before approval" check can't see a prior run's file.
const name = `hello-${Date.now()}.txt`;
await startCoworkSession(page);
// Leave the default "Ask for approval" mode — the write should gate.
await sendTask(page, `Create a file named ${name} containing exactly the text: hello world`);
// The tool call blocks on an approval card, and the file does not exist yet.
await expect(page.getByText("Permission required")).toBeVisible({ timeout: 120_000 });
expect(newestFile(scratchBase!, name), "file must not exist before approval").toBeNull();
// Approve it.
await page.getByRole("button", { name: "Allow once" }).click();
// Now it runs to completion and the artifact lands on disk.
await expect(page.getByText(/Artifacts \(\d+\)/)).toBeVisible({ timeout: 120_000 });
const file = newestFile(scratchBase!, name);
expect(file, `no ${name} found under ${scratchBase}`).toBeTruthy();
expect(readFileSync(file!, "utf8").toLowerCase()).toContain("hello world");
});

View File

@@ -0,0 +1,33 @@
import { test, expect } from "@playwright/test";
import { readFileSync } from "fs";
import { newestFile, scratchBaseIfReady, selectMode, sendTask, startCoworkSession } from "./helpers";
// LIVE end-to-end smoke: drive the real app against the real backend + a real model, ask it to
// produce a file in Full-access mode, and verify the artifact lands on disk with correct contents.
// This is the vertical the hermetic suite mocks (model, tool execution, file I/O, WS streaming).
// Excluded from CI (separate config/dir) — run with `npm run e2e:live`.
const PROMPT =
"Compute the first 20 Fibonacci numbers and write them to fib.md with a one-line explanation at the top.";
// Distinctive Fibonacci values unlikely to appear in prose — a format-tolerant correctness check.
const EXPECTED = ["144", "377", "987", "4181"];
test("live: agent writes fib.md to its scratch workspace, verified on disk", async ({ page }) => {
const scratchBase = await scratchBaseIfReady();
test.skip(!scratchBase, "live backend not ready — start openworker-server and configure a model");
await startCoworkSession(page);
await selectMode(page, "Full access"); // run the write without an approval gate
await sendTask(page, PROMPT);
// The artifact rail gains a file once the write tool has run (model + tool time).
await expect(page.getByText(/Artifacts \(\d+\)/)).toBeVisible({ timeout: 150_000 });
// Verify on disk — the strongest signal that the whole stack worked.
const file = newestFile(scratchBase!, "fib.md");
expect(file, `no fib.md found under ${scratchBase}`).toBeTruthy();
const text = readFileSync(file!, "utf8");
for (const n of EXPECTED) {
expect(text, `fib.md should contain Fibonacci value ${n}`).toContain(n);
}
});

View File

@@ -0,0 +1,16 @@
---
id: e2e-tester
name: E2E Tester
icon: sparkle
tagline: Throwaway persona for the live install smoke test
description: Installed by the persona-install e2e:live test; writes a file on request.
family: knowledge
workspace: deliverable
tools:
- files
default_permission_mode: auto
---
You are the E2E Tester, a persona used only by an automated live test. When the user asks you to
write a file, use your file tools to create it exactly as specified, then confirm in one short
sentence. Do nothing else.

View File

@@ -0,0 +1,85 @@
import { readFileSync, readdirSync, statSync } from "fs";
import { homedir } from "os";
import { join } from "path";
import type { Page } from "@playwright/test";
// Shared helpers for the LIVE smoke specs (real backend + real model). Kept out of the hermetic
// suite (separate dir/config); see e2e/README.md.
export const BACKEND = "http://127.0.0.1:8765";
function sidecarToken(): string {
const state =
process.env.COWORKER_STATE_DIR ||
(process.platform === "win32"
? join(process.env.APPDATA || homedir(), "coworker")
: join(homedir(), ".config", "coworker"));
try {
return readFileSync(join(state, "sidecar-8765.token"), "utf8").trim();
} catch {
return "";
}
}
/** Fetch from the live sidecar with its per-launch authentication token. */
export function backendFetch(path: string, init: RequestInit = {}): Promise<Response> {
const headers = new Headers(init.headers);
const token = sidecarToken();
if (token) headers.set("X-OpenWorker-Token", token);
return fetch(`${BACKEND}${path}`, { ...init, headers });
}
/** The expanded scratch base if the backend is up and a model is ready — else null (→ skip). */
export async function scratchBaseIfReady(): Promise<string | null> {
try {
const res = await backendFetch("/v1/settings");
const s = await res.json();
if (res.ok && s.model_ready) {
return String(s.scratch_base || "~/OpenWorker").replace(/^~(?=\/|$)/, homedir());
}
} catch {
/* backend unreachable */
}
return null;
}
/** Newest `name` file across the per-session scratch dirs (each live session gets its own). */
export function newestFile(scratchBase: string, name: string): string | null {
let best: { path: string; mtime: number } | null = null;
let dirs: string[];
try {
dirs = readdirSync(scratchBase);
} catch {
return null;
}
for (const d of dirs) {
const f = join(scratchBase, d, name);
try {
const st = statSync(f);
if (!best || st.mtimeMs > best.mtime) best = { path: f, mtime: st.mtimeMs };
} catch {
/* not in this session dir */
}
}
return best?.path ?? null;
}
/** Open a fresh Cowork session via the split button's persona menu. */
export async function startCoworkSession(page: Page) {
await page.goto("/");
await page.getByRole("button", { name: "Choose a persona" }).click();
await page.getByText(/Produce a deliverable/).click();
}
/** Switch the composer's permission mode from the default "Ask for approval". */
export async function selectMode(page: Page, label: "Full access" | "Plan" | "Discuss") {
await page.getByText("Ask for approval").click();
await page.getByText(label, { exact: true }).click();
}
/** Type a task and send it. */
export async function sendTask(page: Page, text: string) {
await page.getByPlaceholder(/Ask the coworker/).fill(text);
// exact — "Send" is a substring of the Inbox control's "Sending approvals…" title when unattended.
await page.getByRole("button", { name: "Send", exact: true }).click();
}

View File

@@ -0,0 +1,33 @@
import { test, expect } from "@playwright/test";
import { scratchBaseIfReady, sendTask, startCoworkSession } from "./helpers";
// LIVE — Inbox / Unattended. With "Send to Inbox" on, a tool call that would normally block on an
// inline approval card must instead route to the Inbox (so the agent runs unattended). We assert the
// approval shows up in the Inbox for this session. Excluded from CI — run with `npm run e2e:live`.
test("live: unattended routes an approval to the Inbox", async ({ page }) => {
const scratchBase = await scratchBaseIfReady();
test.skip(!scratchBase, "live backend not ready — start openworker-server and configure a model");
const token = `INBOX-${Date.now()}`;
const name = `inbox-${Date.now()}.txt`;
await startCoworkSession(page);
// Turn on "Send to Inbox" (unattended) via the composer's Inbox control, and wait until it's
// persisted (the icon's title flips to the unattended wording only after setUnattended resolves).
await page.getByRole("button", { name: "Inbox routing" }).click();
await page.getByRole("switch", { name: "Send approvals to the Inbox" }).click();
await expect(page.getByRole("button", { name: /works unattended/ })).toBeVisible();
await page.locator(".fixed.inset-0.z-30").click(); // close the popover
// Keep the default Ask-for-approval mode: the write would normally block inline, but unattended
// routes it to the Inbox.
await sendTask(page, `Write a file named ${name} containing exactly: ${token}`);
// Open the Inbox; the approval appears there (its session chip carries this session's title, which
// is the prompt — so it contains the unique filename).
await page.getByText("Inbox", { exact: true }).click();
await expect(page.getByText(name).first()).toBeVisible({ timeout: 120_000 });
await expect(page.getByRole("button", { name: "Approve" }).first()).toBeVisible();
});

View File

@@ -0,0 +1,33 @@
import { test, expect } from "@playwright/test";
import { scratchBaseIfReady, selectMode, sendTask, startCoworkSession } from "./helpers";
// LIVE #6 — persistence & resume. After a completed turn, reloading the page must not lose the work:
// the session persists in the sidebar and reopens with its full transcript and its artifact. We
// reopen it explicitly (rather than relying on which session auto-restores — several sessions can
// share the same updated_at second). Excluded from CI — run with `npm run e2e:live`.
test("live: a session's transcript and artifact survive a page reload", async ({ page }) => {
const scratchBase = await scratchBaseIfReady();
test.skip(!scratchBase, "live backend not ready — start openworker-server and configure a model");
const token = `PERSIST-${Date.now()}`;
// Unique filename — appears early in the session title (so it survives title truncation and is a
// reliable click target in the sidebar), and is the artifact name.
const name = `note-${Date.now()}.txt`;
await startCoworkSession(page);
await selectMode(page, "Full access");
await sendTask(page, `Write a file named ${name} containing exactly: ${token}`);
// Turn finishes (artifact lands) and the token is in the transcript.
await expect(page.getByText(/Artifacts \(\d+\)/)).toBeVisible({ timeout: 150_000 });
await expect(page.getByText(token).first()).toBeVisible();
// Reload, then reopen this session from the sidebar (it must have persisted there).
await page.reload();
await page.getByText(name).first().click({ timeout: 60_000 });
// Reopened with its transcript restored (the token) and its artifact back on the rail.
await expect(page.getByText(token).first()).toBeVisible({ timeout: 30_000 });
await expect(page.getByText(/Artifacts \(\d+\)/)).toBeVisible({ timeout: 30_000 });
});

View File

@@ -0,0 +1,72 @@
import { test, expect } from "@playwright/test";
import { readFileSync } from "fs";
import { fileURLToPath } from "node:url";
import path from "node:path";
import { newestFile, scratchBaseIfReady, selectMode, sendTask } from "./helpers";
// LIVE capstone — install a persona from a local-directory bundle, enable + surface it, start a
// session as it, and have it do real work. Exercises the whole persona pipeline: manifest parse +
// snapshot on install, lifecycle (enable/surface), session creation, and execution. Excluded from
// CI — run with `npm run e2e:live`. Idempotent: re-installing overwrites the snapshot.
const here = path.dirname(fileURLToPath(import.meta.url));
const FIXTURE_DIR = path.join(here, "fixtures", "persona"); // holds e2e-tester.md
test("live: install a persona from a directory, enable it, and run a task as it", async ({ page }) => {
const scratchBase = await scratchBaseIfReady();
test.skip(!scratchBase, "live backend not ready — start openworker-server and configure a model");
const token = `PERSONA-${Date.now()}`;
const name = `persona-${Date.now()}.txt`;
await page.goto("/");
// Open persona management (Settings ▸ Personas) via the New-session menu.
await page.getByRole("button", { name: "Choose a persona" }).click();
await page.getByText(/Manage personas/).click();
await expect(page.getByText("Add personas")).toBeVisible();
// Install from the local directory bundle.
await page.getByRole("combobox").selectOption("dir");
await page.getByPlaceholder("/path/to/personas").fill(FIXTURE_DIR);
await page.getByRole("button", { name: "Install" }).click();
await expect(page.getByText(/Installed \d+ persona/)).toBeVisible({ timeout: 30_000 });
// Enable + surface it in the picker. Idempotent across re-runs (skip if already on), and click +
// await rather than check() — these are controlled React checkboxes (async updatePersona re-render).
const row = page.locator("div.flex.items-center.gap-4").filter({ hasText: "E2E Tester" });
const ensureChecked = async (i: number) => {
const box = row.getByRole("checkbox").nth(i);
if (!(await box.isChecked())) {
await box.click();
await expect(box).toBeChecked();
}
};
await ensureChecked(0); // Enabled
await ensureChecked(1); // In picker (enabled only once Enabled is on)
// Leave Settings (so the settings rows unmount), then start a fresh session AS the new persona.
// Select by the unique tagline — it appears only on the dropdown item, whereas the name "E2E
// Tester" also shows in the top bar/sidebar once a session is on it.
await page.getByRole("button", { name: "New session" }).click();
await page.getByRole("button", { name: "Choose a persona" }).click();
await page.getByText(/Throwaway persona/).click();
await expect(page.getByText("E2E Tester").first()).toBeVisible(); // the session is this persona
// New sessions start in "Ask for approval" regardless of the persona's declared mode (a safety
// default for freshly-installed personas), so set Full access to let the write run to completion.
await selectMode(page, "Full access");
await sendTask(page, `Write a file named ${name} containing exactly: ${token}`);
// The installed persona should do the work. Non-Cowork personas don't render the Artifacts rail,
// so wait on the file itself (ground truth) rather than a UI signal.
await expect
.poll(
() => {
const f = newestFile(scratchBase!, name);
return f ? readFileSync(f, "utf8") : "";
},
{ timeout: 150_000, message: `${name} with the token never appeared under ${scratchBase}` },
)
.toContain(token);
});

View File

@@ -0,0 +1,77 @@
# E2E tests (Playwright)
End-to-end regression tests for the GUI. They drive the real app in Chromium but are **hermetic**:
every `/v1` request and the event WebSocket are mocked at the network layer, so tests need **no
Python backend**, run deterministically, and never mutate real state.
## Run
```bash
npm run e2e # headless
npm run e2e:ui # Playwright UI mode (watch/inspect)
npx playwright test e2e/settings.spec.ts # a single spec
```
## Live smoke (not CI)
`npm run e2e:live` runs `e2e-live/` (separate `playwright.live.config.ts`) against the **real**
backend on :8765. Two flavors, both skip cleanly when the backend is down:
- **API-shape smoke** (`api-smoke.spec.ts`) — no model tokens, no creds. Asserts `/v1/health` and
`/v1/providers` return the shapes the GUI reads, catching drift between the mocks and the real
backend. Cheap enough to run anytime the sidecar is up.
- **Full vertical** (`fib.spec.ts`, …) — asks a fresh Cowork session to produce `fib.md` and
verifies the file lands on disk. Needs a model configured, is nondeterministic, and costs a few
tokens per run. Exercises the vertical the hermetic specs mock: model wiring, the tool/approval
loop, file I/O, and WebSocket streaming.
The config (`playwright.config.ts`) starts the Vite dev server on port **5199** (dedicated, so it
won't clash with a running `npm run dev` on 5173) and reuses it if already up.
## How the mock works
`e2e/fixtures.ts` exports a `test` whose `page` has `mockApi()` installed before navigation:
- `page.route("**/v1/**", …)` dispatches by pathname + method to fixtures whose shapes mirror the
real backend (captured from a live server). Unknown endpoints return an empty-but-valid body.
- Mutations are held in per-test in-memory state so they reflect through the real UI on re-fetch:
sessions (archive/rename/delete), personas (enable/surface/delete — enable implies surface,
matching the backend), inbox items + the routing binding, roots, channel subscriptions.
- The session WebSocket (`routeWebSocket`) is a **scripted fake agent** speaking the real
`{type, data}` event protocol: `ready` on connect; `user_message``turn_start` → deltas →
`assistant_message "Echo: <text>"``turn_done`; a message containing **"run a tool"** emits
`tool_proposed` + `permission_required` and suspends until the client's `approval` decision
arrives. This runs the production send/stream/approve code paths with zero model cost.
- Seed data worth knowing: the pinned session "Draft the launch note" is the newest (boot-resume
target); 7 unpinned "Weekly plan N" cowork sessions exercise the sidebar peek cap; two pending
Inbox items (approval on cowork, question on ops) drive the Inbox filters; `acme-notes` is a
disabled non-builtin persona for enable/delete flows. Providers are seeded in three states
(OpenAI configured+used, Anthropic configured-unused, Z AI unconfigured w/ prefilled endpoint) —
`POST /v1/providers` flips `configured` on save, `/verify` fails on a key containing "bad". One
automation ("Daily AI News") with a running run — `POST .../run` appends a run, `PATCH`/`DELETE`
toggle and remove.
- **Seeded transcripts**: every session's `GET /v1/sessions/{id}/messages` answers `[]`, so
reopening starts blank. `seedSessionMessages(page, sessionId, messages)` (exported from
fixtures) registers a later, winning route that stages full replayed history for one
session — tool_calls + `role:"tool"` results (wired by `tool_call_id`), `_display`
sidecars, `reasoning`, `notice` markers, connector `source` messages. Use it to assert
the reopen path (`itemsFromMessages`) — replayed step groups, connector cards, tail-error
Retry — which live echo-driving can't reach. See `seeded-history.spec.ts`.
## Adding a spec
```ts
import { test, expect } from "./fixtures";
test("…", async ({ page }) => {
await page.goto("/");
// interact + assert
});
```
If a flow reads a new endpoint, add its fixture + a route branch in `fixtures.ts` — the catch-all
returns `{}`, which will crash components that expect arrays (e.g. persona `recommends`). Prefer
`getByRole`, but note some controls (the Sources bar, the ✕ remove) take their accessible name from
inner content — target those with `getByTitle`/`getByLabel`.
```

View File

@@ -0,0 +1,101 @@
// The rail's Access section (§32 — absorbs the §23 Session-settings drawer; the topbar
// row/glance machinery is retired). Contract: the header carries a PERMANENT summary of what
// the session can touch; expanding edits inline at rail width (no overlay, no dialog).
// Fixture state: browser + slack + github connected/enabled (github is two_way WITHOUT
// channels — relay mentions, no subscriptions), gmail recommended-not-connected, one
// primary root → summary "Browser, Slack +1 · 1 folder".
import { expect } from "@playwright/test";
import { test } from "./fixtures";
test("no topbar opener; the Access header IS the ambient glance; expanding edits inline", async ({
page,
}) => {
await page.goto("/");
await page.getByText("Draft the launch note").first().click();
// §32: the settings row/icon is gone from the topbar — the panel toggle is the one entry.
await expect(page.getByRole("button", { name: "Open session settings" })).toHaveCount(0);
await expect(page.getByTestId("session-settings-row")).toHaveCount(0);
// The trust surface is ambient once More is unfolded: the collapsed header always shows
// the summary — and no nudge text ever renders at rest (§23's rule carried over).
const section = page.getByTestId("access-section");
await expect(section.getByTestId("access-summary")).toHaveText("Browser, Slack +1 · 1 folder");
await expect(section.getByText(/recommended/i)).toHaveCount(0);
// Expand → Sources (per-session toggles), Recommended (with its reason), Folders — all
// inline in the rail; no dialog appears anywhere.
await section.getByTestId("access-toggle").click();
const body = page.getByRole("region", { name: "Session access" });
await expect(body.getByText("Sources")).toBeVisible();
await expect(body.getByText("Slack", { exact: true })).toBeVisible();
await expect(body.getByText("email context for morning summaries")).toBeVisible();
await expect(body.getByTestId("drawer-directories").getByText("Temporary folder")).toBeVisible();
await expect(page.getByRole("dialog")).toHaveCount(0);
// Channels is a chat capability, not a two_way one: Slack gets the drill-down, GitHub
// (two_way via the relay, no channel semantics) must NOT (owner report 2026-07-13).
await expect(body.getByRole("button", { name: /Channels ·/ })).toHaveCount(1);
await expect(body.getByText("GitHub", { exact: true })).toBeVisible();
});
test("+ Add a source: full catalog on focus, filter as you type → connect-in-context; connected sources never match", async ({
page,
}) => {
await page.goto("/");
await page.getByText("Draft the launch note").first().click();
await page.getByTestId("access-toggle").click();
// Focusing the empty input shows the FULL catalog (FB-012) — every available connector
// minus the already-connected three, before any typing.
await page.getByTestId("access-add-source").click();
const search = page.getByTestId("access-add-search");
await expect(search).toBeFocused();
const rows = page.locator('[data-testid^="access-add-"]:not([data-testid="access-add-search"])');
await expect(rows).toHaveCount(9); // 12 in the catalog browser/slack/github (connected)
await expect(page.getByTestId("access-add-notion")).toBeVisible();
// Already-connected sources don't match (Slack and GitHub are connected in fixtures)…
await search.fill("slack");
await expect(page.getByText("No match — see all on the Connectors page below.")).toBeVisible();
await search.fill("github");
await expect(page.getByText("No match — see all on the Connectors page below.")).toBeVisible();
// …and clearing the query restores the full list ("filter as you type", not search-only).
await search.fill("");
await expect(rows).toHaveCount(9);
// Capability aliases match too: "calendar" surfaces Outlook (title alone never would).
await search.fill("calendar");
await expect(page.getByTestId("access-add-outlook")).toBeVisible();
// …the long tail does: Notion is in the catalog but neither connected nor recommended.
await search.fill("notion");
await page.getByTestId("access-add-notion").click();
// Lands in the SAME connect-in-context child view the Recommended flow uses, with the
// scope-semantics line; back returns to the Sources list.
const body = page.getByRole("region", { name: "Session access" });
await expect(body.getByText("Connecting makes Notion available to all your coworkers", { exact: false })).toBeVisible();
await expect(body.getByPlaceholder("ntn_…")).toBeVisible();
await body.getByRole("button", { name: "Back to sources" }).click();
await expect(body.getByText("Slack", { exact: true })).toBeVisible();
});
test("per-session mute round-trips; the summary follows", async ({ page }) => {
await page.goto("/");
await page.getByText("Draft the launch note").first().click();
const section = page.getByTestId("access-section");
await section.getByTestId("access-toggle").click();
const body = page.getByRole("region", { name: "Session access" });
// Muting Slack for this session drops it from the live summary (the fixture flips
// enabled on POST and the section reloads).
await body
.getByTitle(
"On for this session. Off mutes it for this session only — the connector stays connected.",
)
.nth(1)
.click();
await expect(section.getByTestId("access-summary")).toHaveText("Browser, GitHub · 1 folder");
});

View File

@@ -0,0 +1,78 @@
// The generic multi-account detail page (AccountsDetail) + the modal's generic
// one-click pane, exercised via Notion — the pattern all batch-2 connectors
// share (accounts.py layer: AccountRow shape, Default badge, per-account ×).
import { expect } from "@playwright/test";
import { test } from "./fixtures";
async function openConnectors(page) {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Connectors", exact: true }).click();
}
async function signInAndConnectFirstWorkspace(page) {
await openConnectors(page);
await page.getByTestId("account-row").click();
await page.getByTestId("account-sign-in").click();
await expect(page.getByTestId("account-row")).toContainText("Rohit", { timeout: 10_000 });
// Available row → modal with One click | Manual pills → generic one-click
await page
.getByTestId("connector-notion")
.getByRole("button", { name: "Connect", exact: true })
.click();
await expect(page.getByTestId("modal-pane-manual")).toBeVisible();
await page.getByTestId("modal-generic-one-click").click();
await page.keyboard.press("Escape");
await expect(page.getByTestId("connector-notion")).toContainText("Rohit's Workspace", {
timeout: 10_000,
});
}
test("one-click connect, add a second workspace from the page; first stays default", async ({
page,
}) => {
await signInAndConnectFirstWorkspace(page);
await page.getByTestId("connector-notion").click();
await expect(page.getByTestId("accounts-detail")).toBeVisible();
await page.getByTestId("add-account-btn").click();
const first = page.getByTestId("account-ws-1");
const second = page.getByTestId("account-ws-2");
await expect(second).toBeVisible({ timeout: 10_000 });
await expect(first).toContainText("Rohit's Workspace");
await expect(first).toContainText("Default");
await expect(second).not.toContainText("Default");
// list row summarizes the multi-account state
await page.getByTestId("connectors-breadcrumb").click();
await expect(page.getByTestId("connector-notion")).toContainText("2 accounts");
});
test("Make default moves the badge; disconnecting the default repoints it", async ({
page,
}) => {
await signInAndConnectFirstWorkspace(page);
await page.getByTestId("connector-notion").click();
await page.getByTestId("add-account-btn").click();
await expect(page.getByTestId("account-ws-2")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("account-make-default-ws-2").click();
await expect(page.getByTestId("account-ws-2")).toContainText("Default");
await expect(page.getByTestId("account-ws-1")).not.toContainText("Default");
await page.getByTestId("account-disconnect-ws-2").click();
await expect(page.getByTestId("account-ws-2")).toHaveCount(0);
await expect(page.getByTestId("account-ws-1")).toContainText("Default");
});
test("signed out: the modal's one-click pane offers inline cloud sign-in; manual pane has the token form", async ({
page,
}) => {
await openConnectors(page);
await page
.getByTestId("connector-notion")
.getByRole("button", { name: "Connect", exact: true })
.click();
await expect(page.getByTestId("inline-cloud-sign-in")).toBeVisible();
await page.getByTestId("modal-pane-manual").click();
await expect(page.getByPlaceholder("ntn_…")).toBeVisible();
});

View File

@@ -0,0 +1,99 @@
// §35 (UX-018): approval cards speak the transcript's language. Routine workspace writes
// are a compact ROW (humanized title, inline args-preview, short "Allow for this session"
// with the full rule on hover); everything else is a full card — shell titles with the model's
// description, external actions wear the leaves-this-Mac note. No "PERMISSION REQUIRED"
// kicker, no raw args dump, no solid-fill buttons.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
test("routine write → compact row: humanized title, inline preview, Allow resolves", async ({
page,
}) => {
await page.goto("/");
const box = page.getByPlaceholder(/Ask the coworker/);
await box.fill("please write a file");
await page.getByRole("button", { name: "Send" }).click();
const row = page.getByTestId("approval-row");
await expect(row).toContainText("Write fetch_data.py");
await expect(row).not.toContainText(/permission required/i);
await expect(
row.getByRole("button", { name: "Allow for this session", exact: true }),
).toHaveAttribute("title", /rest of this session/);
// Preview expands INLINE from the tool args — the file doesn't exist yet.
await row.getByText("preview ▾").click();
await expect(row).toContainText("import json");
await row.getByText("show all 6 lines").click();
await expect(row).toContainText("done = True");
await page.screenshot({ path: "test-results/ux018-compact-row.png", fullPage: false });
await row.getByRole("button", { name: "Allow", exact: true }).click();
await expect(page.getByText(/Done via write_file/)).toBeVisible();
});
test("run_shell → full card: description title, command preview, stays-on-this-Mac note", async ({
page,
}) => {
await page.goto("/");
const box = page.getByPlaceholder(/Ask the coworker/);
await box.fill("please run a tool");
await page.getByRole("button", { name: "Send" }).click();
// The mocked proposal has no description → plain "Run a command" title; the command is
// the preview; the reason still renders; the scope note replaces the old badge.
await expect(page.getByText("Run a command").last()).toBeVisible();
await expect(page.getByText("stays on this computer").last()).toBeVisible();
await expect(page.getByText("The coworker wants to run a command.").first()).toBeVisible();
await expect(
page.getByRole("button", { name: "Allow this command for this session" }).last(),
).toBeVisible();
await expect(page.getByText(/local action/)).toHaveCount(0);
await page.screenshot({ path: "test-results/ux018-shell-card.png", fullPage: false });
await page.getByRole("button", { name: "Allow once" }).last().click();
await expect(page.getByText("The command ran; 1 file found.")).toBeVisible();
});
test("a one-paragraph digest send is clamped to a card, expandable in place", async ({
page,
}) => {
await page.goto("/");
const box = page.getByPlaceholder(/Ask the coworker/);
await box.fill("post the long digest");
await page.getByRole("button", { name: "Send" }).click();
// The message rides in a clamped preview box — not an unbounded quote wall.
const prev = page.locator(".approval-prev");
await expect(prev).toBeVisible();
await expect(prev).toContainText("aisuite — last 24 hours");
const clampedHeight = (await prev.boundingBox())!.height;
expect(clampedHeight).toBeLessThan(200);
await page.screenshot({ path: "test-results/send-digest-clamped.png", fullPage: false });
// Expands in place, and can collapse back.
await prev.getByText("show the full message").click();
expect((await prev.boundingBox())!.height).toBeGreaterThan(clampedHeight);
await expect(prev.getByText("show less")).toBeVisible();
});
test("read-only session grant: offered on classified commands, resolves the card", async ({
page,
}) => {
await page.goto("/");
const box = page.getByPlaceholder(/Ask the coworker/);
await box.fill("please run a tool");
await page.getByRole("button", { name: "Send" }).click();
// The mocked `ls` proposal carries readonly_ok → the session-wide grant is offered.
const btn = page.getByTestId("allow-readonly-session");
await expect(btn).toBeVisible();
await expect(btn).toHaveAttribute("title", /no network, writes, or interpreters/);
await btn.click();
// Grant approves the pending call; the turn proceeds like any approval.
await expect(page.getByText(/The command ran; 1 file found/)).toBeVisible();
});

View File

@@ -0,0 +1,106 @@
// OPE-91: agent-authored HTML renders in the artifact viewer inside an AIRTIGHT sandbox.
// The app webview is privileged (Tauri IPC), so the report page must be null-origin
// (no parent access) and offline (no subresource exfiltration) — while inline scripts,
// the thing report interactivity needs, keep working. The fixture page actively probes
// all three properties and reports into #probe.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
async function openReport(page: import("@playwright/test").Page) {
await page.goto("/");
await page.getByPlaceholder(/Ask the coworker/).fill("hello");
await page.getByRole("button", { name: "Send" }).click();
// Seventeenth pass: sections start collapsed — expand Artifacts to reach the list.
await page.getByTestId("rail-toggle-artifacts").click();
await page.locator(".artifact-row", { hasText: "security-review.html" }).click();
}
test("HTML artifact renders sandboxed: scripts run, parent and network stay sealed", async ({
page,
}) => {
await openReport(page);
const frame = page.getByTestId("artifact-frame");
await expect(frame).toBeVisible();
// No allow-same-origin, ever: with srcDoc it would run the page same-origin with the
// privileged app webview. This assertion is the regression lock for that exact flag.
await expect(frame).toHaveAttribute("sandbox", "allow-scripts");
const probe = page.frameLocator('[data-testid="artifact-frame"]').locator("#probe");
await expect(probe).toContainText("script ran in sandbox"); // interactivity works
await expect(probe).toContainText("parent blocked"); // null origin held
await expect(probe).toContainText("network blocked"); // CSP stopped the exfil img
await expect(page).not.toHaveTitle("ESCAPED");
});
test("HTML artifact offers Open in browser as the unsandboxed escape hatch", async ({
page,
}) => {
await openReport(page);
// UX-038: the open action lives in the labeled ⋯ menu now.
await page.getByTestId("artifact-more").click();
await expect(page.getByTestId("artifact-open-browser")).toBeVisible();
await expect(page.getByTestId("artifact-copy-contents")).toBeVisible();
await expect(page.getByTestId("artifact-copy-path")).toBeVisible();
});
test("a transcript chip opens the viewer on the FIRST click even with the rail hidden", async ({
page,
}) => {
// Owner-hit 2026-08-15: the chip fires one event; the rail's select-listener was only
// registered while the rail was visible, so click #1 unhid an empty rail and the
// selection was lost — the viewer appeared only on a later click.
await page.goto("/");
await page.getByPlaceholder(/Ask the coworker/).fill("show the report");
await page.getByRole("button", { name: "Send" }).click();
await page.getByRole("button", { name: "Hide side panel" }).click();
await page.getByTestId("artifact-chip").click();
await expect(page.getByTestId("artifact-frame")).toBeVisible();
});
test("Artifacts section renders for a folder-gated coworker too (universal scratch)", async ({
page,
}) => {
// UX-036: every session has a scratch surface, so the drawer's Artifacts section is no
// longer cowork-only — a security session lists its scratch-side reports the same way.
await page.goto("/");
await page.getByTestId("coworker-chip").click();
await page.locator(".setup-menu").getByRole("button", { name: /Security Coworker/ }).click();
await page.getByPlaceholder(/Ask the coworker/).fill("audit this repo");
await page.getByRole("button", { name: "Send" }).click();
await page.getByTestId("send-folder-dialog").getByRole("button", { name: "Choose a folder…" }).click();
await expect(page.getByText(/Echo: audit this repo/)).toBeVisible();
await expect(page.getByTestId("rail-toggle-artifacts")).toBeVisible();
await page.getByTestId("rail-toggle-artifacts").click();
await expect(page.locator(".artifact-row", { hasText: "security-review.html" })).toBeVisible();
});
test("Show sidebar sticks while the artifact viewer is open", async ({ page }) => {
// Owner-hit 2026-08-21: opening the viewer auto-collapses the nav (one-shot
// courtesy), but clicking "Show sidebar" then instantly re-collapsed it — the
// notify effect replayed "open" on a callback identity change. The user's
// explicit toggle must win.
await openReport(page);
await expect(page.getByRole("button", { name: "Show sidebar" })).toBeVisible();
await page.getByRole("button", { name: "Show sidebar" }).click();
await page.waitForTimeout(400); // give a regression time to re-collapse
await expect(page.getByRole("button", { name: "Show sidebar" })).toHaveCount(0);
await expect(page.getByText("New session").first()).toBeVisible();
// The viewer stays open too — expanding the nav is navigation, not dismissal.
await expect(page.getByTestId("artifact-frame")).toBeVisible();
});
test("viewer breadcrumb goes back and ✕ closes (UX-038)", async ({ page }) => {
await openReport(page);
// The breadcrumb parent is the back action — returns to the rail sections.
await page.getByTestId("artifact-crumb-back").click();
await expect(page.getByTestId("rail-toggle-artifacts")).toBeVisible();
// Reopen (the section is still expanded from openReport), then ✕ closes the same way.
await page.locator(".artifact-row", { hasText: "security-review.html" }).click();
await expect(page.getByTestId("artifact-frame")).toBeVisible();
await page.getByTestId("artifact-close").click();
await expect(page.getByTestId("rail-toggle-artifacts")).toBeVisible();
});

View File

@@ -0,0 +1,153 @@
import type { Page } from "@playwright/test";
import { test, expect } from "./fixtures";
// OPE-51 — ask_user upgrades: rich options (descriptions, the Recommended tag, monospace
// previews with the two-pane layout) and grouped questions (the stepper). Seeded via a per-test
// inbox route override (later routes match first) so the base fixtures' counts — which
// inbox.spec.ts pins — stay untouched.
const BASE = {
body: "",
state: "pending",
resolution: null as string | null,
inbox: "default",
created_at: "2026-07-29 08:00:00",
resolved_at: null as string | null,
session_title: "Investigate alerts",
session_agent: "ops",
session_workspace: "",
session_exists: true,
};
const RICH_ITEM = {
...BASE,
id: "inb-question-rich",
session_id: "ops-1",
kind: "question",
title: "How should I format the report?",
header: "Format",
options: [
{
label: "Markdown table",
description: "Compact and renders in the app",
recommended: true,
preview: "| env | status |\n| --- | --- |\n| staging | ok |",
},
{
label: "Plain text",
description: "Safest for email forwarding",
preview: "env: staging\nstatus: ok",
},
],
allow_text: true,
multi: false,
questions: [],
};
const GROUPED_ITEM = {
...BASE,
id: "inb-question-grouped",
session_id: "ops-1",
kind: "question",
// The first question doubles as title/options (legacy-surface degradation, server parity).
title: "Chart style?",
header: "Chart style",
options: ["Bar", "Line"],
allow_text: false,
multi: false,
questions: [
{ question: "Chart style?", header: "Chart style", options: ["Bar", "Line"], allow_text: false, multi: false },
{ question: "Which distribution?", header: "Distribution", options: ["Stacked", "Grouped"], allow_text: true, multi: false },
],
};
/** Replace the Inbox's seeded items for this test (resolve mutates the local copy). */
async function seedInbox(page: Page, items: Record<string, unknown>[]) {
const inbox = items.map((i) => ({ ...i }));
const json = (body: unknown) => ({
status: 200,
contentType: "application/json",
body: JSON.stringify(body),
});
await page.route(/\/v1\/inbox\/[^/]+\/resolve$/, (route) => {
const path = new URL(route.request().url()).pathname;
const id = decodeURIComponent(path.split("/").slice(-2)[0]);
const it = inbox.find((x) => x.id === id);
if (it) {
it.state = "resolved";
it.resolution = route.request().postDataJSON().resolution;
}
return route.fulfill(json({ ok: true }));
});
await page.route(/\/v1\/inbox(\?.*)?$/, (route) =>
route.fulfill(json({ items: inbox.filter((i) => i.state === "pending") })),
);
return inbox;
}
async function openInbox(page: Page, expectTitle: string) {
await page.goto("/");
await page.getByTestId("inbox-chip").click();
await expect(page.getByText(expectTitle)).toBeVisible();
}
test("rich options render descriptions + Recommended; the preview pane follows hover", async ({
page,
}) => {
await seedInbox(page, [RICH_ITEM]);
await openInbox(page, "How should I format the report?");
await expect(page.getByText("Compact and renders in the app")).toBeVisible();
await expect(page.getByText("Recommended")).toBeVisible();
// The pane opens on the first option holding a preview…
const pane = page.getByTestId("question-preview");
await expect(pane).toContainText("| env | status |");
// …and follows hover to the other option.
await page.getByRole("button", { name: /Plain text/ }).hover();
await expect(pane).toContainText("env: staging");
// Single-select still resolves on click, with the option's LABEL as the resolution.
const resolved = page.waitForRequest(
(r) => r.url().includes("/resolve") && r.method() === "POST",
);
await page.getByRole("button", { name: /Markdown table/ }).click();
expect((await resolved).postDataJSON().resolution).toBe("Markdown table");
await expect(page.getByText("How should I format the report?")).not.toBeVisible();
});
test("grouped questions step through the header chips and resolve as one answer map", async ({
page,
}) => {
await seedInbox(page, [GROUPED_ITEM]);
await openInbox(page, "Chart style?");
// Step 1: "Chart style · 1 of 2 · Distribution " — and no free-text row (allow_text: false).
const stepper = page.getByTestId("question-stepper");
await expect(stepper).toContainText("Chart style");
await expect(stepper).toContainText("1 of 2");
await expect(stepper).toContainText("Distribution ");
await expect(page.getByPlaceholder("Or type your own answer…")).not.toBeVisible();
// Answering advances to step 2 (its free-text escape is back — allow_text: true).
await page.getByRole("button", { name: "Bar", exact: true }).click();
await expect(stepper).toContainText("2 of 2");
await expect(page.getByText("Which distribution?")).toBeVisible();
await expect(page.getByPlaceholder("Or type your own answer…")).toBeVisible();
// steps back with the first answer re-askable; answer forward again.
await page.getByRole("button", { name: "Previous question" }).click();
await expect(stepper).toContainText("1 of 2");
await page.getByRole("button", { name: "Bar", exact: true }).click();
await expect(stepper).toContainText("2 of 2");
// The final answer resolves the whole card with a JSON map keyed by header.
const resolved = page.waitForRequest(
(r) => r.url().includes("/resolve") && r.method() === "POST",
);
await page.getByRole("button", { name: "Stacked", exact: true }).click();
expect((await resolved).postDataJSON().resolution).toBe(
JSON.stringify({ "Chart style": "Bar", Distribution: "Stacked" }),
);
await expect(page.getByText("Nothing pending.")).toBeVisible();
});

View File

@@ -0,0 +1,44 @@
// UX-026: the automation-start toast — top-right, 5s, schedule-fired runs only.
// The server pushes automation_run_started over the app-wide /ws/events stream;
// the toast names the automation, offers one View-run action (opens the run's
// session), an ✕, and auto-dismisses via the drain bar.
import { expect } from "@playwright/test";
import { sendAppEvent, test } from "./fixtures";
const RUN_STARTED = {
type: "automation_run_started",
data: {
task_id: "task-1",
task_title: "Daily AI News",
session_id: "run-live-1",
workspace: "/tmp/aw",
agent: "cowork",
trigger: "schedule",
},
};
test("a schedule-fired run pops the toast; View run opens its session", async ({ page }) => {
await page.goto("/");
await sendAppEvent(page, RUN_STARTED);
const toast = page.getByTestId("automation-toast");
await expect(toast).toContainText("Automation started");
await expect(toast).toContainText("Daily AI News");
await toast.getByTestId("toast-view-run").click();
await expect(page.getByTestId("automation-toast")).toHaveCount(0);
// the run's session is now the active conversation (composer visible = session surface)
await expect(page.getByPlaceholder(/Ask the coworker/)).toBeVisible();
});
test("the toast dismisses on ✕ and by itself after ~5s", async ({ page }) => {
await page.goto("/");
await sendAppEvent(page, RUN_STARTED);
await expect(page.getByTestId("automation-toast")).toBeVisible();
await page.getByTestId("toast-dismiss").click();
await expect(page.getByTestId("automation-toast")).toHaveCount(0);
await sendAppEvent(page, { ...RUN_STARTED, data: { ...RUN_STARTED.data, task_title: "Weekly CRM digest" } });
await expect(page.getByTestId("automation-toast")).toContainText("Weekly CRM digest");
// auto-dismiss: gone within the 5s drain (+ slack for CI)
await expect(page.getByTestId("automation-toast")).toHaveCount(0, { timeout: 7000 });
});

View File

@@ -0,0 +1,51 @@
// Automations management — the parts of Rohit's manual pass that automations.spec.ts (run-banner +
// Back) doesn't cover: the task list, triggering a manual run (POST .../run appends a run and opens
// its live session), pausing via the enable toggle, and deleting. Seeded with one task.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
async function openAutomations(page) {
await page.goto("/");
await page.getByTestId("nav-automations").click();
await expect(page.getByText("Recurring tasks OpenWorker runs on a schedule.")).toBeVisible();
}
test("lists a scheduled task with its schedule and run count", async ({ page }) => {
await openAutomations(page);
const card = page.locator(".sched-card", { hasText: "Daily AI News" });
await expect(card).toBeVisible();
await expect(card).toContainText("Every day at ~5:40 PM");
await expect(card).toContainText("last running");
});
test("Run now triggers a manual run and opens its live session", async ({ page }) => {
await openAutomations(page);
await page.locator(".sched-card", { hasText: "Daily AI News" }).click();
await page.getByRole("button", { name: /Run now/ }).click();
// The manual run opens as a session with the automation-context banner.
const banner = page.getByTestId("run-banner");
await expect(banner).toBeVisible();
await expect(banner).toContainText("Daily AI News");
});
test("enable toggle pauses the task", async ({ page }) => {
await openAutomations(page);
await page.locator(".sched-card", { hasText: "Daily AI News" }).click();
await expect(page.getByText(/Active · next/)).toBeVisible();
// The checkbox is visually hidden behind a styled slider — click the label wrapper.
await page.locator("label.switch").click();
await expect(page.getByText("Paused", { exact: false })).toBeVisible();
});
test("delete removes the task; deleting the last one shows the empty state", async ({ page }) => {
await openAutomations(page);
await page.locator(".sched-card", { hasText: "Daily AI News" }).click();
await page.getByRole("button", { name: /Delete/ }).click();
// Back on the list, the deleted task is gone; the other seeded task remains.
await expect(page.locator(".sched-card", { hasText: "Daily AI News" })).toHaveCount(0);
await expect(page.locator(".sched-card", { hasText: "Weekly CRM digest" })).toHaveCount(1);
await page.locator(".sched-card", { hasText: "Weekly CRM digest" }).click();
await page.getByRole("button", { name: /Delete/ }).click();
await expect(page.getByText(/No scheduled tasks yet/)).toBeVisible();
});

View File

@@ -0,0 +1,125 @@
// The Automations quickstart (UX-DECISIONS §29): ONE template system — the former onboarding
// recipe (role templates, connect rows, lazy cloud sign-in, §25 consent) merged into the page's
// "Start from a template" grid. Cards carry §27's connector-dot vocabulary; picking one expands
// the configure card. The `ob-*` testids moved here with the machinery.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
async function openAutomations(page) {
await page.goto("/");
await page.getByTestId("nav-automations").click();
await expect(page.getByText("Recurring tasks OpenWorker runs on a schedule.")).toBeVisible();
}
// The fixtures seed one task, so the quickstart isn't on the bare list — surface it via the
// "+ New automation" toggle (empty state shows it without the toggle; covered indirectly by
// the delete test in automations-manage.spec.ts).
async function openQuickstart(page) {
await openAutomations(page);
await page.getByRole("button", { name: "+ New automation" }).click();
await expect(page.getByText("Start from a template")).toBeVisible();
}
test("role recipe: connect rows, lazy single sign-in, channel by name, consent mints the grant", async ({
page,
}) => {
await openQuickstart(page);
// Pipeline digest: Slack is connected in fixtures, HubSpot isn't. No recipe form yet.
await page.getByTestId("qs-template-pipeline").click();
const cfg = page.getByTestId("qs-configure");
// §30: the card names its template — "SET UP · Pipeline digest" — instead of starting
// abruptly after the grid.
await expect(cfg).toContainText("Set up");
await expect(cfg).toContainText("Pipeline digest");
await expect(cfg.getByText("✓ Connected").first()).toBeVisible();
await expect(page.getByTestId("ob-recipe")).toHaveCount(0);
await expect(page.getByTestId("ob-create")).toBeDisabled();
await expect(page.getByTestId("ob-create-hint")).toContainText("Connect HubSpot");
// Connect HubSpot while signed out → the ONE cloud pane appears; signing in finishes the
// pending connect without another click.
await page.getByTestId("ob-connect-hubspot").click();
await expect(page.getByTestId("ob-cloudpane")).toBeVisible();
await page.getByTestId("ob-cloud-signin").click();
await expect(page.getByTestId("ob-recipe")).toBeVisible({ timeout: 15_000 });
// Connected but no channel → the gate names the missing piece (tester catch 2026-07-12).
await expect(page.getByTestId("ob-create-hint")).toContainText("Pick a channel");
// Channel picked BY NAME; §25 consent pre-checked; create lands on the task's detail with
// the standing grant listed.
const chan = page.locator('[data-testid="ob-channel"] input');
await chan.click();
await page.getByTestId("channel-suggestions").getByText("#ocw-test").click();
await expect(chan).toHaveValue("#ocw-test");
await expect(page.getByTestId("ob-consent")).toBeChecked();
await page.getByTestId("ob-create").click();
await expect(page.getByRole("button", { name: /Run now/ })).toBeVisible();
await expect(page.getByText("Pipeline digest").first()).toBeVisible();
await expect(page.getByTestId("task-grants")).toContainText("send_message");
});
test("connect narrates itself: Opening browser → waiting strip → Cancel restores the button", async ({
page,
}) => {
await openQuickstart(page);
// Sign in out-of-band so Connect goes straight to the broker flow (no cloud pane).
await page.evaluate(() => fetch("/v1/cloud/login", { method: "POST" }));
// Hold the connect POST open (§30's 45 s of dead air) and never flip the fixture's
// connected state — the waiting strip owns the gap until the user acts.
let release: (() => void) | undefined;
const held = new Promise<void>((r) => (release = r));
await page.route(/\/v1\/connectors\/hubspot\/connect-managed$/, async (route) => {
await held;
await route.fulfill({ json: { ok: true } });
});
await page.getByTestId("qs-template-pipeline").click();
// The mount refresh must land the signed-in status before Connect is clicked, or the
// click would open the sign-in pane instead of the broker flow.
await page.waitForResponse(/\/v1\/cloud\/status/);
await page.getByTestId("ob-connect-hubspot").click();
await expect(page.getByText("Opening browser…")).toBeVisible();
release!();
await expect(page.getByText("Waiting for HubSpot…")).toBeVisible();
await expect(page.getByTestId("ob-connect-wait")).toContainText(
"Finish connecting HubSpot in your browser",
);
// Cancel clears only the LOCAL waiting state — the Connect button returns.
await page.getByTestId("ob-connect-cancel").click();
await expect(page.getByTestId("ob-connect-wait")).toHaveCount(0);
await expect(page.getByTestId("ob-connect-hubspot")).toBeVisible();
});
test("read-only recipe (Morning brief) carries disclosure, not a grant", async ({ page }) => {
await openQuickstart(page);
await page.getByTestId("qs-template-brief").click();
// Calendar + Gmail rows; no consent checkbox anywhere — reads never gate.
await expect(page.getByText("Today's meetings and gaps")).toBeVisible();
await expect(page.getByText("What arrived overnight")).toBeVisible();
await expect(page.getByTestId("ob-consent")).toHaveCount(0);
});
test("no-connection template: When is editable and create opens the detail", async ({ page }) => {
await openQuickstart(page);
// The card says so on its face.
await expect(page.getByTestId("qs-template-news")).toContainText("No connections needed");
await page.getByTestId("qs-template-news").click();
// No connect rows, no consent — just When (day × time) and an enabled Create.
await expect(page.getByTestId("ob-consent")).toHaveCount(0);
await expect(
page.getByTestId("ob-recipe").getByRole("button", { name: "Day" }),
).toContainText("Every day");
await expect(page.getByTestId("ob-create")).toBeEnabled();
await page.getByTestId("ob-create").click();
await expect(page.getByRole("button", { name: /Run now/ })).toBeVisible();
await expect(page.getByText("Morning news briefing").first()).toBeVisible();
});

View File

@@ -0,0 +1,32 @@
import { test, expect } from "./fixtures";
// Automation runs open as live sessions — which used to look like any other chat with no way
// back (owner report, 2026-07-04). Guards: the run-session banner (task title + automation
// context) and "← Back to runs" returning to the task's detail page.
test("scheduled run session shows the run banner; Back returns to the task detail", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("nav-automations").click();
// Task list → detail (runs list).
await page.getByText("Daily AI News").first().click();
await expect(page.getByRole("button", { name: /Run now/ })).toBeVisible();
await expect(page.getByText("Each run is a live conversation", { exact: false })).toBeVisible();
// Open the running run: a normal session view, but with the automation-context banner.
await page.getByTitle("Open this run's conversation").click();
const banner = page.getByTestId("run-banner");
await expect(banner).toBeVisible();
await expect(banner).toContainText("Scheduled run");
await expect(banner).toContainText("Daily AI News");
// Back link lands on the SAME task's detail, not the bare list.
await banner.getByRole("button", { name: "← Back to runs" }).click();
await expect(page.getByRole("button", { name: /Run now/ })).toBeVisible();
await expect(page.getByText("Daily AI News").first()).toBeVisible();
// A plain (non-run) session never shows the banner.
await page.getByText("Draft the launch note").first().click();
await expect(page.getByTestId("run-banner")).toHaveCount(0);
});

View File

@@ -0,0 +1,48 @@
// Pre-connect connector detail page (UX-DECISIONS §38): an AVAILABLE row
// navigates to a subpage with the About paragraph, honest Access bullets, and
// the tool list behind a collapsed disclosure; Connect opens the same modal as
// the list's pill (which itself must NOT navigate).
import { expect } from "@playwright/test";
import { test } from "./fixtures";
async function openConnectors(page) {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Connectors", exact: true }).click();
}
test("available row opens the pre-connect detail page", async ({ page }) => {
await openConnectors(page);
await page.getByTestId("connector-gmail").click();
const detail = page.getByTestId("available-detail");
await expect(detail).toContainText("Search, summarize, and send over your Gmail.");
await expect(page.getByTestId("available-access")).toContainText("Reads and searches your mail.");
await expect(detail).toContainText("Keys and tokens are stored only on this computer");
// Tools are a collapsed disclosure — advanced detail, closed by default.
await expect(detail).toContainText("2 tools this connector adds");
await expect(detail).not.toContainText("Send email");
await page.getByTestId("available-tools-toggle").click();
await expect(detail).toContainText("Send email");
await expect(detail).toContainText("asks first"); // write tools carry the tag
// Breadcrumb returns to the list.
await page.getByTestId("connectors-breadcrumb").click();
await expect(page.getByTestId("connector-gmail")).toBeVisible();
});
test("detail Connect opens the modal; the list pill skips navigation", async ({ page }) => {
await openConnectors(page);
await page.getByTestId("connector-gmail").click();
await page.getByTestId("available-connect").click();
await expect(page.getByTestId("add-connection-modal")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByTestId("add-connection-modal")).not.toBeVisible();
// Back on the list, the pill goes straight to the modal — no detail page.
await page.getByTestId("connectors-breadcrumb").click();
await page.getByTestId("connector-gmail").getByRole("button", { name: "Connect" }).click();
await expect(page.getByTestId("add-connection-modal")).toBeVisible();
await expect(page.getByTestId("available-detail")).not.toBeVisible();
});

View File

@@ -0,0 +1,161 @@
// Agent teams: the board in the session UI — the rail section (grouped by state,
// blocked on top, active work only) and the expanded overlay: a quiet list over
// the store's RAW states (In progress / Awaiting review / Queued — no computed
// interpretation layer, no row buttons) plus a detail pane with the item's merged
// event timeline. Verdicts flow through the pane: Mark done / Request changes….
// The fake agent files items on "plan the work"; transitions round-trip through
// the mocked /board endpoints as the user.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
async function planTheWork(page: import("@playwright/test").Page) {
await page.goto("/");
await page.getByPlaceholder(/Ask the coworker/).fill("plan the work");
await page.getByRole("button", { name: "Send" }).click();
await expect(page.getByText(/filed 5 work items/)).toBeVisible();
}
// Seventeenth pass: every drawer section starts collapsed — expanding the Board
// section is now an explicit step wherever a test reads the rail's rows.
async function openBoardSection(page: import("@playwright/test").Page) {
await page.getByTestId("rail-toggle-board").click();
await expect(page.getByTestId("board-rail")).toBeVisible();
}
test("plain sessions carry zero board chrome", async ({ page }) => {
await page.goto("/");
await page.getByPlaceholder(/Ask the coworker/).fill("hello");
await page.getByRole("button", { name: "Send" }).click();
await expect(page.getByText("Echo: hello")).toBeVisible();
await expect(page.getByTestId("board-rail")).toHaveCount(0);
await expect(page.getByTestId("rail-toggle-board")).toHaveCount(0);
});
test("filed items appear grouped in the rail, blocked on top, queued items listed", async ({
page,
}) => {
await planTheWork(page);
// collapsed by default: the header chip is the maximum signal
await expect(page.getByTestId("board-rail")).toHaveCount(0);
await expect(page.getByTestId("rail-toggle-board")).toContainText("1 blocked · 1 review");
await openBoardSection(page);
const rail = page.getByTestId("board-rail");
await expect(rail).toBeVisible();
const groups = rail.locator(".board-group");
await expect(groups.first()).toHaveText("Blocked");
await expect(rail).toContainText("Queued");
await expect(rail.getByText("Secrets — git history, both repos")).toBeVisible();
});
test("the overlay lists raw-state sections; verdicts flow through the detail pane", async ({
page,
}) => {
await planTheWork(page);
await page.getByTestId("board-expand").click();
const overlay = page.getByTestId("board-overlay");
await expect(overlay).toBeVisible();
// the owner's sections, nothing computed — and no buttons in the rows
await expect(overlay).toContainText("In progress");
await expect(overlay).toContainText("Awaiting review");
await expect(overlay).toContainText("Queued");
await expect(overlay.getByRole("button", { name: "Mark done" })).toHaveCount(0);
// a blocked row carries the blocker as a plain fact under In progress
await expect(page.getByTestId("board-item-4")).toContainText(
"cloud-posture · blocked: need tfvars for staging",
);
// review verdict from the pane
await page.getByTestId("board-item-5").click();
const detail = page.getByTestId("board-detail");
await detail.getByRole("button", { name: "Mark done" }).click();
await expect(page.getByTestId("overlay-finished-toggle")).toHaveText("1 finished · show");
// queued items are removed from their pane (maps to canceled underneath)
await page.getByTestId("board-item-1").click();
await detail.getByRole("button", { name: "Remove" }).click();
await expect(page.getByTestId("overlay-finished-toggle")).toHaveText("2 finished · show");
await page.keyboard.press("Escape");
await expect(page.getByTestId("board-overlay")).toHaveCount(0);
});
test("finished items leave the rail; a quiet toggle reveals them", async ({ page }) => {
await planTheWork(page);
await openBoardSection(page);
const rail = page.getByTestId("board-rail");
await expect(rail.getByText("Report rollup")).toBeVisible(); // review = active
await page.getByTestId("board-expand").click();
await page.getByTestId("board-item-5").click();
await page.getByTestId("board-detail").getByRole("button", { name: "Mark done" }).click();
await page.keyboard.press("Escape");
// done vanishes from the rail — a fresh session on an old board starts calm
await expect(rail.getByText("Report rollup")).toHaveCount(0);
const toggle = page.getByTestId("board-finished-toggle");
await expect(toggle).toHaveText("1 finished · show");
await toggle.click();
await expect(rail.getByText("Report rollup")).toBeVisible();
await toggle.click();
await expect(rail.getByText("Report rollup")).toHaveCount(0);
});
test("item detail: timeline with attachment, worker link, request changes", async ({
page,
}) => {
await planTheWork(page);
await openBoardSection(page);
// a rail row deep-opens the overlay on that item's detail
await page.getByTestId("board-rail").getByText("Report rollup").click();
const detail = page.getByTestId("board-detail");
await expect(detail).toBeVisible();
await expect(detail).toContainText("#5");
await expect(detail).toContainText("Report rollup");
await expect(detail).toContainText("In review");
await expect(detail).toContainText("Done when");
// the merged timeline tells the item's whole story
await expect(detail).toContainText("security started");
await expect(detail).toContainText("balances reconcile against the seeded rows");
await expect(detail).toContainText("moved to in review");
// the attachment image actually loads (real bytes from the fixture)
await expect(detail.getByTestId("board-attachment")).toBeVisible();
// the assignee links to that coworker's session
await expect(detail.getByTestId("board-open-worker")).toHaveText("security ↗");
// Request changes… discloses a comment box; sending returns the item to work
await detail.getByRole("button", { name: "Request changes…" }).click();
await detail.getByPlaceholder("What needs to change?").fill("totals drift on Tom");
await detail.getByRole("button", { name: "Request changes", exact: true }).click();
await expect(detail).toContainText("In progress");
// switching rows switches the pane
await page.getByTestId("board-item-3").click();
await expect(detail).toContainText("Dependency audit — lockfiles");
});
test("Add a note is a pure append — it lands in the timeline, state untouched", async ({
page,
}) => {
await planTheWork(page);
await openBoardSection(page);
await page.getByTestId("board-rail").getByText("Report rollup").click();
const detail = page.getByTestId("board-detail");
await expect(detail).toContainText("In review");
await detail.getByTestId("board-note-input").fill("prefer the v2 endpoint for totals");
await detail.getByTestId("board-note-input").press("Enter");
// the note appears as a timeline event…
await expect(detail).toContainText("user commented");
await expect(detail).toContainText("prefer the v2 endpoint for totals");
// …and the state did NOT change (notes never transition)
await expect(detail).toContainText("In review");
await expect(detail.getByRole("button", { name: "Mark done" })).toBeVisible();
});
test("journal section lists cases once a board exists", async ({ page }) => {
await planTheWork(page);
// Journal is not a primary section — it sits behind the quiet More row.
await expect(page.getByTestId("rail-toggle-journal")).toHaveCount(0);
await page.getByTestId("rail-toggle-journal").click();
const journal = page.getByTestId("journal-list");
await expect(journal).toBeVisible();
await expect(journal).toContainText("findings");
await expect(journal).toContainText("12 entries");
// Access folds with it — the drawer keeps three primary sections.
await expect(page.getByTestId("access-section")).toBeVisible();
});

View File

@@ -0,0 +1,75 @@
// Cold-boot fixes (owner-hit 2026-07-23): the splash wears the real OpenWorker mark
// (6-point star SVG, not the ✦ text glyph that read as another product's logo), and the
// model picker recovers when the mount-time settings fetch loses the race against the
// sidecar boot — previously "Loading models…" stuck until the user visited Settings.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
test("boot splash shows the OpenWorker star, not the sparkle glyph", async ({ page }) => {
// Hold health long enough to observe the splash.
await page.route("**/v1/health", async (route) => {
await new Promise((r) => setTimeout(r, 1500));
await route.fallback();
});
await page.goto("/");
const mark = page.locator(".boot-mark");
await expect(mark).toBeVisible();
await expect(mark.locator("svg")).toBeVisible(); // the Icon logo, not a text glyph
await expect(mark).not.toContainText("✦");
await expect(page.getByText(/Starting OpenWorker|Restoring your session/)).toBeVisible();
});
test("model picker recovers when settings fetches die during sidecar boot", async ({ page }) => {
// Real cold-start shape: EVERY request fails until the sidecar is up (health included),
// then everything answers. The mount-time settings fetches all lose that race and are
// swallowed — the post-health reload must populate the picker without a Settings visit.
let sidecarUp = false;
await page.route("**/v1/health", async (route) => {
await new Promise((r) => setTimeout(r, 700));
sidecarUp = true;
await route.fallback();
});
await page.route("**/v1/settings", async (route) => {
if (route.request().method() === "GET" && !sidecarUp) {
await route.abort();
return;
}
await route.fallback();
});
await page.goto("/");
await expect(page.locator(".dd").filter({ hasText: "Claude Opus 4.8" })).toBeVisible({
timeout: 10_000,
});
await expect(page.getByTestId("models-loading")).toHaveCount(0);
});
test("coworker picker recovers when the persona fetch dies during sidecar boot", async ({
page,
}) => {
// Same cold-start shape as above, for /v1/personas (owner-hit 2026-08-13, packaged app):
// the mount-time fetch loses to the sidecar boot and its only other trigger is
// PERSONAS_CHANGED, so the composer's picker stayed empty for the WHOLE session — while
// Settings ▸ Coworkers (mounted later) listed everything and looked healthy.
let sidecarUp = false;
await page.route("**/v1/health", async (route) => {
await new Promise((r) => setTimeout(r, 700));
sidecarUp = true;
await route.fallback();
});
await page.route("**/v1/personas", async (route) => {
if (route.request().method() === "GET" && !sidecarUp) {
await route.abort();
return;
}
await route.fallback();
});
await page.goto("/");
await page.getByText("New session").first().click();
await page.getByTestId("coworker-chip").click();
// The menu must list real coworkers, not just its Import/Manage footer.
const menu = page.locator(".setup-menu");
await expect(menu.getByText("Security Coworker")).toBeVisible({ timeout: 10_000 });
await expect(menu.getByTestId("import-coworker")).toBeVisible();
});

View File

@@ -0,0 +1,93 @@
import { test, expect } from "./fixtures";
// The core loop: boot-resume into the last session, send a message over the WebSocket, and render
// the streamed reply — plus the in-session approval round-trip (permission_required suspends the
// turn until Allow/Deny goes back over the socket). The fake agent lives in fixtures.ts.
test("send → user bubble → streamed echo reply renders", async ({ page }) => {
await page.goto("/");
// Boot resumes the most recent session ("Draft the launch note") and connects; the composer is
// live once the fake agent's `ready` lands.
const box = page.getByPlaceholder(/Ask the coworker/);
await expect(box).toBeVisible();
await box.fill("hello agent");
await page.getByRole("button", { name: "Send" }).click();
// Local echo of the user message, then the agent's reply (delta-streamed, then finalized).
await expect(page.getByText("hello agent", { exact: true }).first()).toBeVisible();
await expect(page.getByText(/Echo: hello agent/)).toBeVisible();
// The message carried the composer's visible model (model-per-message contract): what the
// user sees at send time is exactly what serves the turn.
await expect(page.getByText("[model=anthropic:claude-opus-4-8]")).toBeVisible();
// …and the picker STAYS actionable after the first turn (§17 rev 2026-07-22 — mid-session
// switching shipped); the fact also reads in the topbar's facts subtitle.
await expect(page.locator(".dd").filter({ hasText: "Claude Opus" })).toBeVisible();
await expect(page.getByTestId("session-subtitle")).toContainText("Claude Opus 4.8");
// Composer cleared and re-armed for the next turn.
await expect(box).toHaveValue("");
});
test("approval: tool request suspends the turn; Allow once resumes it", async ({ page }) => {
await page.goto("/");
const box = page.getByPlaceholder(/Ask the coworker/);
await expect(box).toBeVisible();
await box.fill("please run a tool");
await page.getByRole("button", { name: "Send" }).click();
// The approval card surfaces the tool + reason and blocks until a decision.
await expect(page.getByText("The coworker wants to run a command.").first()).toBeVisible();
await page.getByRole("button", { name: "Allow once" }).last().click();
// Decision goes back over the socket; the agent finishes the tool and the turn.
await expect(page.getByText("The command ran; 1 file found.")).toBeVisible();
});
test("approval: Deny skips the tool and the agent says so", async ({ page }) => {
await page.goto("/");
const box = page.getByPlaceholder(/Ask the coworker/);
await expect(box).toBeVisible();
await box.fill("please run a tool");
await page.getByRole("button", { name: "Send" }).click();
await expect(page.getByRole("button", { name: "Deny" }).last()).toBeVisible();
await page.getByRole("button", { name: "Deny" }).last().click();
await expect(page.getByText("Understood — skipped the command.")).toBeVisible();
});
test("long user pastes clamp with a more…/less… toggle", async ({ page }) => {
await page.goto("/");
const box = page.getByPlaceholder(/Ask the coworker/);
await expect(box).toBeVisible();
const tail = "END-OF-PASTE-MARKER";
const paste =
"reply OK. " + "lorem ipsum dolor sit amet consectetur ".repeat(60) + tail; // ~2.4k chars
await box.fill(paste);
await page.getByRole("button", { name: "Send" }).click();
// Clamped: the bubble shows the head but not the tail, plus the toggle.
const more = page.getByRole("button", { name: "more…" });
await expect(more).toBeVisible();
const bubble = page.locator(".bubble-user").last();
await expect(bubble).toContainText("reply OK.");
await expect(bubble).not.toContainText(tail);
// Expand → full text + "less…"; collapse → clamped again.
await more.click();
await expect(bubble).toContainText(tail);
const less = page.getByRole("button", { name: "less…" });
await expect(less).toBeVisible();
await less.click();
await expect(bubble).not.toContainText(tail);
// Short messages never show the control.
await expect(page.getByText("Echo:").first()).toBeVisible();
await box.fill("short follow-up");
await page.getByRole("button", { name: "Send" }).click();
await expect(page.getByText("short follow-up", { exact: true }).first()).toBeVisible();
await expect(page.getByRole("button", { name: "more…" })).toHaveCount(1); // still only the paste's
});

View File

@@ -0,0 +1,43 @@
// Regression guard (shipped once, 2026-07-09; reshaped by §26): cloud sign-in must be
// reachable by a FRESH user. The sidebar account row is the permanent sign-in home —
// always visible, never below any fold — and every signed-out one-click pane carries a
// real Sign-in button, not a hint pointing at another page.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
async function openConnectors(page) {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByTestId("account-menu").getByRole("button", { name: "Connectors", exact: true }).click();
}
test("the account row is always visible and signs in from its menu", async ({ page }) => {
await page.goto("/");
const row = page.getByTestId("account-row");
await expect(row).toBeVisible();
await expect(row).toContainText("Not signed in");
await row.click();
await page.getByTestId("account-sign-in").click();
await expect(row).toContainText("Rohit", { timeout: 10_000 });
// Sign out is right there in the same menu once signed in.
await row.click();
await expect(
page.getByTestId("account-menu").getByRole("button", { name: "Sign out" }),
).toBeVisible();
});
test("signed-out one-click pane signs in inline, then connects", async ({ page }) => {
await openConnectors(page);
// Fresh user path: Available → Connect → the pane must offer sign-in itself.
await page
.getByTestId("connector-gmail")
.getByRole("button", { name: "Connect", exact: true })
.click();
await page.getByTestId("inline-cloud-sign-in").click();
// The mock signs in instantly; the section's poll re-renders the pane armed.
await expect(
page.getByRole("button", { name: /Connect Gmail with one click/i }),
).toBeVisible({ timeout: 10_000 });
});

View File

@@ -0,0 +1,53 @@
// FB-013: a signed-in user opened the rail's connect pane and was told to sign in —
// the rail's single cloud-status fetch rendered PENDING (and any failure) as signed-out,
// with nothing that could ever flip it back. Contract now: unknown status shows a neutral
// "checking" line, never the sign-in ask; the pane polls while open; and completing
// sign-in from the inline prompt flips the pane itself (no other section's poll needed).
import { expect } from "@playwright/test";
import { test } from "./fixtures";
const openGmailPane = async (page: import("@playwright/test").Page) => {
await page.goto("/");
await page.getByText("Draft the launch note").first().click();
await page.getByTestId("access-toggle").click();
await page.getByTestId("access-add-source").click();
await page.getByTestId("access-add-gmail").click();
};
test("pending status shows 'checking', never the sign-in ask; resolves to one-click", async ({
page,
}) => {
// Hold every /v1/cloud/status response (test routes outrank the fixture's) — the user
// IS signed in, the app just doesn't know yet.
let release!: () => void;
const gate = new Promise<void>((r) => (release = r));
await page.route("**/v1/cloud/status", async (route) => {
await gate;
await route.fulfill({
json: { signed_in: true, account: "her@example.com", user_id: "u1", telemetry_enabled: true },
});
});
await openGmailPane(page);
await expect(page.getByTestId("cloud-status-pending")).toBeVisible();
await expect(page.getByTestId("inline-cloud-sign-in")).toHaveCount(0);
release();
await expect(page.getByRole("button", { name: "Connect Gmail with one click" })).toBeVisible();
await expect(page.getByTestId("cloud-status-pending")).toHaveCount(0);
});
test("signing in from the rail prompt flips the pane to one-click", async ({ page }) => {
// Fixture default: signed out — the resolved signed-out state legitimately asks.
await openGmailPane(page);
const ask = page.getByTestId("inline-cloud-sign-in");
await expect(ask).toBeVisible();
await expect(page.getByTestId("cloud-status-pending")).toHaveCount(0);
// The mock login flips CLOUD_STATE instantly; the inline button's own post-login poll
// plus the CLOUD_CHANGED broadcast must flip THIS pane without any other page open.
await ask.click();
await expect(page.getByRole("button", { name: "Connect Gmail with one click" })).toBeVisible({
timeout: 5_000,
});
});

View File

@@ -0,0 +1,81 @@
// Cloud sign-in (§26: the sidebar account row is the sign-in home) + managed one-click
// connectors. Product invariant under test: manual token setup is always present; managed
// one-click is an ADDITION that appears only when signed in.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
async function openConnectors(page) {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByTestId("account-menu").getByRole("button", { name: "Connectors", exact: true }).click();
await expect(page.getByRole("heading", { name: "Connectors" })).toBeVisible();
}
async function signIn(page) {
await page.getByTestId("account-row").click();
await page.getByTestId("account-sign-in").click();
await expect(page.getByTestId("account-row")).toContainText("Rohit", { timeout: 10_000 });
}
test("signed out: the account row is the sign-in home; managed connector still connects manually", async ({
page,
}) => {
await page.goto("/");
const row = page.getByTestId("account-row");
await expect(row).toContainText("Not signed in");
// The menu leads with the sign-in CTA and always lists Inbox + Connectors.
await row.click();
const menu = page.getByTestId("account-menu");
await expect(menu).toContainText("one-click connections need OpenWorker Cloud");
await expect(menu.getByTestId("account-sign-in")).toBeVisible();
await expect(menu.getByRole("button", { name: "Inbox" })).toBeVisible();
await menu.getByRole("button", { name: "Connectors", exact: true }).click();
// The managed-capable connector's add-modal shows the hint + manual fields, no
// one-click button while signed out.
await page.getByTestId("connector-gmail").getByRole("button", { name: "Connect" }).click();
const modal = page.getByTestId("add-connection-modal");
await expect(modal.getByTestId("managed-connect")).toContainText("Sign in to OpenWorker Cloud");
await expect(modal.locator("input[type=password]")).toBeVisible(); // manual field rendered
await expect(modal.getByRole("button", { name: /one click/i })).toHaveCount(0);
});
test("signed in: account row shows the name; one-click appears; sign out from the menu", async ({
page,
}) => {
await openConnectors(page);
await signIn(page);
await page.getByTestId("connector-gmail").getByRole("button", { name: "Connect", exact: true }).click();
const modal = page.getByTestId("add-connection-modal");
await expect(modal.getByRole("button", { name: /Connect Gmail with one click/i })).toBeVisible();
// the manual path must still be offered alongside
await expect(modal.getByTestId("managed-connect")).toContainText("or connect manually");
await page.keyboard.press("Escape");
// The menu header carries the email; Sign out flips the row back.
await page.getByTestId("account-row").click();
const menu = page.getByTestId("account-menu");
await expect(menu).toContainText("rohit@openworker.com");
await menu.getByRole("button", { name: "Sign out" }).click();
await page.getByTestId("account-row").click(); // reopen → status refetch
await expect(page.getByTestId("account-row")).toContainText("Not signed in");
});
test("telemetry/Privacy card is gone from Settings (owner ask 2026-07-22), signed in or out", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByTestId("account-menu").getByRole("button", { name: "Settings" }).click();
await expect(page.getByRole("heading", { name: "General" })).toBeVisible();
await expect(page.getByTestId("telemetry-toggle")).toHaveCount(0);
await expect(page.getByText("Privacy", { exact: true })).toHaveCount(0);
await signIn(page);
await page.getByTestId("account-row").click();
await page.getByTestId("account-menu").getByRole("button", { name: "Settings" }).click();
await expect(page.getByTestId("telemetry-toggle")).toHaveCount(0);
await expect(page.getByText("Privacy", { exact: true })).toHaveCount(0);
});

View File

@@ -0,0 +1,80 @@
// OPE-27 — auto-compaction GUI: the Settings card's two overrides + summarizer-model
// pin POST through, and the "context compacted" divider renders inline mid-session
// (driven by the fixtures' scripted `compacted` event) without touching the transcript.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
test("Settings: Context compaction card edits threshold, cap, and summarizer model", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Settings", exact: true }).click();
await page.getByRole("button", { name: "Context optimization", exact: true }).click();
const card = page.getByTestId("compaction-card");
await expect(card).toBeVisible();
await expect(card.getByText("Context compaction")).toBeVisible();
// Defaults render when the backend doesn't send the fields (older-backend robustness).
await expect(card.getByTestId("compaction-threshold")).toHaveValue("80");
await expect(card.getByTestId("compaction-cap")).toHaveValue("250000");
await expect(card.getByTestId("compaction-model")).toHaveValue("");
// Threshold edits POST as a fraction, clamped to 1095%.
const [req] = await Promise.all([
page.waitForRequest(
(r) => r.url().endsWith("/v1/settings/compaction") && r.method() === "POST",
),
card.getByTestId("compaction-threshold").fill("70"),
]);
expect(req.postDataJSON()).toEqual({ compaction_threshold_pct: 0.7 });
const [req2] = await Promise.all([
page.waitForRequest(
(r) => r.url().endsWith("/v1/settings/compaction") && r.method() === "POST",
),
card.getByTestId("compaction-cap").fill("100000"),
]);
expect(req2.postDataJSON()).toEqual({ compaction_cap_tokens: 100000 });
// Summarizer pin: the picker offers the session-default plus the configured models.
const [req3] = await Promise.all([
page.waitForRequest(
(r) => r.url().endsWith("/v1/settings/compaction") && r.method() === "POST",
),
card.getByTestId("compaction-model").selectOption("gpt-4o-mini"),
]);
expect(req3.postDataJSON()).toEqual({ compaction_model: "gpt-4o-mini" });
});
test("the compacted divider renders mid-session and the transcript stays intact", async ({
page,
}) => {
await page.goto("/");
await page.getByText("Draft the launch note").first().click();
const box = page.getByPlaceholder(/Ask the coworker/);
// An earlier exchange that must survive the compaction marker (transcript intact).
await box.fill("remember the launch date");
await box.press("Enter");
await expect(page.getByText("Echo: remember the launch date").first()).toBeVisible({
timeout: 10_000,
});
await box.fill("compact the context");
await box.press("Enter");
// The transient signal shows while the summarizer runs, then yields to the divider.
await expect(page.getByText("Compacting context…").first()).toBeVisible({
timeout: 10_000,
});
await expect(
page.getByText("Context compacted — earlier turns were summarized").first(),
).toBeVisible({ timeout: 10_000 });
await expect(page.getByText("Compacting context…")).toHaveCount(0);
await expect(
page.getByText("Still on it — continuing where I left off.").first(),
).toBeVisible();
// Outbound-only: everything before the divider is still on screen.
await expect(page.getByText("Echo: remember the launch date").first()).toBeVisible();
});

View File

@@ -0,0 +1,18 @@
import { test, expect } from "./fixtures";
// The composer must never advertise models the backend didn't confirm: before the
// /v1/settings list arrives (cold app boot races the sidecar), the picker is a
// disabled "Loading models…" chip — NOT a hardcoded fallback list, which went stale
// and offered phantom ids (caught by owner, 2026-07-21).
test("picker shows a disabled Loading-models chip until the list arrives", async ({ page }) => {
await page.route("**/v1/settings", (r) =>
r.fulfill({
json: { model: "gpt-5.5", models: [], model_labels: {}, has_key: true, model_ready: true, onboarded: true, nav_layout: "flat" },
}),
);
await page.goto("/");
const chip = page.getByTestId("models-loading");
await expect(chip).toBeVisible();
await expect(chip).toBeDisabled();
await expect(chip).toContainText("Loading models…");
});

View File

@@ -0,0 +1,23 @@
import { test, expect } from "./fixtures";
// The macOS overlay layout (traffic-light insets) must never apply on Windows —
// Windows keeps its native title bar (alignment bug, 2026-07-21). The shell injects
// __OCW_PLATFORM__; this simulates each platform and checks the overlay class.
test("windows platform gets no tauri-overlay layout", async ({ page }) => {
await page.addInitScript(() => {
(window as any).__TAURI__ = {}; // simulate the desktop shell
(window as any).__OCW_PLATFORM__ = "windows";
});
await page.goto("/");
await expect(page.locator("html")).toHaveAttribute("data-platform", "windows");
await expect(page.locator(".app.tauri-overlay")).toHaveCount(0);
});
test("macos platform keeps the overlay layout", async ({ page }) => {
await page.addInitScript(() => {
(window as any).__TAURI__ = {};
(window as any).__OCW_PLATFORM__ = "macos";
});
await page.goto("/");
await expect(page.locator(".app.tauri-overlay").first()).toBeVisible();
});

View File

@@ -0,0 +1,98 @@
import { test, expect } from "./fixtures";
// Guards the three-control composer row (§22): send-gating (accent only with content), the "+"
// attach menu, and the Mode menu (permission options + the folded-in Send-to-Inbox toggle).
test("composer: send-gating, + attach menu, Mode menu", async ({ page }) => {
await page.goto("/");
await page.getByText("Draft the launch note").first().click();
const box = page.getByPlaceholder(/Ask the coworker/);
const send = page.getByRole("button", { name: "Send" });
// Send is subtle grey when empty, accent once there's content, grey again when cleared.
await expect(send).not.toHaveClass(/bg-accent/);
await box.fill("hello there");
await expect(send).toHaveClass(/bg-accent/);
await box.fill("");
await expect(send).not.toHaveClass(/bg-accent/);
// "+" attach menu offers the three typed shortcuts.
await page.getByRole("button", { name: "Attach" }).click();
await expect(page.getByRole("button", { name: "Photo or image" })).toBeVisible();
await expect(page.getByRole("button", { name: "PDF", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Other files" })).toBeVisible();
// Clicking the backdrop closes it.
await page.locator(".fixed.inset-0.z-30").click();
await expect(page.getByRole("button", { name: "Photo or image" })).toHaveCount(0);
// Mode menu: the three shipped permission options with the current one marked, plus the
// Unattended/send-to-Inbox toggle (§22). Plan + Custom hidden for this release (2026-07-22).
await page.getByRole("button", { name: "Mode", exact: true }).click();
const menu = page.getByTestId("mode-menu");
await expect(menu.getByText("Discuss")).toBeVisible();
await expect(menu.getByText("Plan", { exact: true })).toHaveCount(0);
await expect(menu.getByText("Custom", { exact: true })).toHaveCount(0);
// The current mode is marked with a ✓.
await expect(menu.locator("button").filter({ hasText: "Ask for approval" })).toContainText("✓");
await expect(menu.getByRole("switch", { name: "Send approvals to the Inbox" })).toBeVisible();
// Picking an option closes the menu (and would flip the live engine's mode).
await menu.getByText("Bypass approvals").click();
await expect(page.getByTestId("mode-menu")).toHaveCount(0);
});
// PDFs read as data URLs and show a named chip (DMG #29 walkthrough catch: PDFs silently
// no-op'd because readFile only handled images and text).
test("composer: picking a PDF shows an attachment chip and arms send", async ({ page }) => {
await page.goto("/");
await page.getByText("Draft the launch note").first().click();
const send = page.getByRole("button", { name: "Send" });
await expect(send).not.toHaveClass(/bg-accent/);
await page.locator('input[type="file"]').setInputFiles({
name: "report.pdf",
mimeType: "application/pdf",
buffer: Buffer.from("%PDF-1.4\n1 0 obj\n<<>>\nendobj\ntrailer\n<<>>\n%%EOF"),
});
const chip = page.locator(".attach-chip");
await expect(chip).toContainText("report.pdf");
await expect(send).toHaveClass(/bg-accent/); // attachment alone arms send
// Removing the chip disarms send again.
await chip.locator(".attach-x").click();
await expect(page.locator(".attach-chip")).toHaveCount(0);
await expect(send).not.toHaveClass(/bg-accent/);
});
// Token-savings threshold (owner ask, 2026-07-17): a PDF over the user's page limit is
// REJECTED with a visible notice — no chip, send stays disarmed. Fixture limit: 2 pages;
// the mock inspect endpoint reads the page count from a "%%pages=N" marker in the body.
test("composer: PDF over the page threshold is rejected with a notice", async ({ page }) => {
await page.goto("/");
await page.getByText("Draft the launch note").first().click();
await page.locator('input[type="file"]').setInputFiles({
name: "big-report.pdf",
mimeType: "application/pdf",
buffer: Buffer.from("%PDF-1.4\n%%pages=34\ntrailer\n<<>>\n%%EOF"),
});
const notice = page.getByTestId("attach-notice");
await expect(notice).toContainText("big-report.pdf skipped");
await expect(notice).toContainText("34 pages is over your 2-page limit");
await expect(page.locator(".attach-chip")).toHaveCount(0);
await expect(page.getByRole("button", { name: "Send" })).not.toHaveClass(/bg-accent/);
// The ✕ dismisses the notice.
await notice.getByRole("button").click();
await expect(page.getByTestId("attach-notice")).toHaveCount(0);
// A small PDF (1 page per the mock) still attaches fine after a rejection.
await page.locator('input[type="file"]').setInputFiles({
name: "small.pdf",
mimeType: "application/pdf",
buffer: Buffer.from("%PDF-1.4\n%%pages=1\ntrailer\n<<>>\n%%EOF"),
});
await expect(page.locator(".attach-chip")).toContainText("small.pdf");
});

View File

@@ -0,0 +1,62 @@
// Slack config is a detail SUBPAGE under Connectors (UX-DECISIONS §21): the list row
// navigates to it, and the §19 flows — parked senders (Allow & deliver / Allow / ×)
// and "listening" sessions — are filed under the workspace they belong to.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
async function openSlackPage(page) {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Connectors", exact: true }).click();
await page.getByTestId("connector-slack").click();
}
test("list row status + navigation to the Slack page", async ({ page }) => {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Connectors", exact: true }).click();
const row = page.getByTestId("connector-slack");
await expect(row).toContainText("2 workspaces · relay");
await row.click();
await expect(page.getByTestId("slack-workspaces")).toBeVisible();
// signed out (fixture default) → the status line leads with the actionable layer
await expect(page.getByTestId("slack-mode-badge")).toContainText("Sign-in needed");
});
test("parked sender files under ITS workspace; Allow & deliver adds to that allow-list only", async ({
page,
}) => {
await openSlackPage(page);
// pk1 belongs to T1DL — its Waiting row renders in that workspace's group only.
const t1 = page.getByTestId("slack-workspace-T1DL");
await expect(t1.getByTestId("waiting-pk1")).toContainText("Maya");
await expect(t1.getByTestId("waiting-pk1")).toContainText("in #ocw-test");
await expect(t1.getByTestId("waiting-pk1")).toContainText("hey ocw, can you summarize this thread?");
await expect(page.getByTestId("slack-workspace-T2AC").getByTestId("waiting-pk1")).toHaveCount(0);
await page.getByTestId("parked-allow-deliver-pk1").click();
await expect(page.getByTestId("waiting-pk1")).toHaveCount(0);
// The sender lands on the T1DL allow-list; the sibling workspace stays empty.
await expect(t1).toContainText("U0NEW");
await expect(page.getByTestId("slack-workspace-T2AC")).not.toContainText("U0NEW");
});
test("parked sender can be dismissed without allowing", async ({ page }) => {
await openSlackPage(page);
await page.getByTestId("parked-dismiss-pk1").click();
await expect(page.getByTestId("waiting-pk1")).toHaveCount(0);
await expect(page.getByTestId("slack-workspace-T1DL")).not.toContainText("U0NEW");
});
test("sessions listening in a workspace: listed with unsubscribe", async ({ page }) => {
await openSlackPage(page);
const t1 = page.getByTestId("slack-workspace-T1DL");
await expect(t1.getByTestId("listening-slack")).toContainText("Weekly plan 1");
await expect(t1.getByTestId("listening-slack")).toContainText("#ocw-test");
await t1.getByTitle("Unsubscribe this session").click();
await expect(t1.getByTestId("listening-slack")).toHaveCount(0); // row hides when empty
});

View File

@@ -0,0 +1,66 @@
// The Connectors LIST (UX-DECISIONS §21): connected connectors first in their own
// section with a health chip, rows navigate to the connector's detail subpage
// (breadcrumb back), available connectors get a Connect pill → add-connection modal
// with One click | Manual pills for multi-mode connectors.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
async function openConnectors(page) {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Connectors", exact: true }).click();
}
test("connected connectors come first with status + health chip", async ({ page }) => {
await openConnectors(page);
const slack = page.getByTestId("connector-slack");
await expect(slack).toContainText("2 workspaces · relay");
// signed out + relay mode → the honest chip is the actionable one
await expect(slack).toContainText("Sign-in needed");
// available section renders the not-connected connectors with a Connect pill
await expect(
page.getByTestId("connector-telegram").getByRole("button", { name: "Connect" }),
).toBeVisible();
});
test("row navigates to the detail subpage; breadcrumb returns", async ({ page }) => {
await openConnectors(page);
await page.getByTestId("connector-slack").click();
await expect(page.getByTestId("slack-workspaces")).toBeVisible();
await page.getByTestId("connectors-breadcrumb").click();
await expect(page.getByTestId("connector-slack")).toContainText("2 workspaces · relay");
});
test("generic detail page: tools + two-way blocks + disconnect for telegram-alikes", async ({
page,
}) => {
await openConnectors(page);
// Browser is keyless-connected → generic page, no Disconnect for auth=none
await page.getByTestId("connector-browser").click();
await expect(page.getByRole("heading", { name: "Browser" })).toBeVisible();
await expect(page.getByRole("button", { name: "Disconnect" })).toHaveCount(0);
await page.getByTestId("connectors-breadcrumb").click();
});
test("Connect on a multi-mode connector opens the modal with One click | Manual pills", async ({
page,
}) => {
await openConnectors(page);
// make slack disconnected for this test: disconnect both workspaces via its page is
// heavy — instead assert the modal via the detail page's Add workspace in the slack spec;
// here we verify the generic modal path with telegram (single-mode → ConnectSetup pane).
await page.getByTestId("connector-telegram").getByRole("button", { name: "Connect" }).click();
const modal = page.getByTestId("add-connection-modal");
await expect(modal).toBeVisible();
await expect(modal.locator("input")).not.toHaveCount(0); // manual fields rendered
await page.keyboard.press("Escape");
await expect(page.getByTestId("add-connection-modal")).toHaveCount(0);
});
test("filter narrows both sections", async ({ page }) => {
await openConnectors(page);
await page.getByPlaceholder("Search").fill("tele");
await expect(page.getByTestId("connector-telegram")).toBeVisible();
await expect(page.getByTestId("connector-slack")).toHaveCount(0);
});

View File

@@ -0,0 +1,49 @@
// Model-layer roadmap item 1 (2026-07-22): a turn that dies on a provider error leaves a
// visible, persistent marker with a Retry affordance. Retry re-runs the failed turn with NO
// new user bubble; once the turn recovers, the button disappears (the notice is history).
import { expect } from "@playwright/test";
import { test } from "./fixtures";
test("provider error shows a retriable notice; Retry re-runs without a new user message", async ({
page,
}) => {
await page.goto("/");
await page.getByText("Draft the launch note").first().click();
const box = page.getByPlaceholder(/Ask the coworker/);
await box.fill("please fail the turn");
await box.press("Enter");
await expect(page.getByText("Error: model unreachable").first()).toBeVisible({ timeout: 10_000 });
const retry = page.getByTestId("notice-retry");
await expect(retry).toBeVisible();
await retry.click();
await expect(page.getByText("Recovered after retry.").first()).toBeVisible({ timeout: 10_000 });
// No fake user bubble from the retry turn, exactly one real one…
await expect(page.locator(".bubble-user")).toHaveCount(1);
// …and the button is gone now that the error notice is no longer the transcript tail.
await expect(page.getByTestId("notice-retry")).toHaveCount(0);
});
test("Retry survives a model switch — the intended recovery path", async ({ page }) => {
await page.goto("/");
await page.getByText("Draft the launch note").first().click();
const box = page.getByPlaceholder(/Ask the coworker/);
await box.fill("please fail the turn");
await box.press("Enter");
await expect(page.getByTestId("notice-retry")).toBeVisible({ timeout: 10_000 });
// Switch models: the info marker lands AFTER the error — Retry must stay offered
// (owner-hit 2026-07-23: the switch notices consumed it).
const picker = page.locator(".dd").filter({ hasText: "Claude Opus 4.8" });
await picker.locator(".pill").click();
await page.locator(".dd-item").filter({ hasText: "GPT-5.5" }).click();
await expect(page.getByText(/Model switched to gpt-5.5/).first()).toBeVisible();
const retry = page.getByTestId("notice-retry");
await expect(retry).toBeVisible();
await retry.click();
await expect(page.getByText("Recovered after retry.").first()).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId("notice-retry")).toHaveCount(0);
});

View File

@@ -0,0 +1,130 @@
import { test, expect } from "./fixtures";
// The persona's requires_folder trait decides workspace behavior
// (workspace-scratch-design.md), enforced at the SEND moment:
// requires_folder → send with no folder → "Where should … work?" dialog (recents /
// native picker / "Start in a temporary folder", git-init'd, created
// only now). Exercised through Security Coworker — the enabled gated
// persona in the shipped lineup (Code ships disabled).
// everything else → starts orphan on a transparent temporary dir — never gated
// The coworker pick lives in the setup chip row above the composer, only before the
// first message of a new session; afterwards the row leaves and the facts move to the
// session header.
async function newDraftAs(page: import("@playwright/test").Page, coworker: RegExp) {
await page.getByText("New session").first().click();
await page.getByTestId("coworker-chip").click();
await page.locator(".setup-menu").getByRole("button", { name: coworker }).click();
}
test("scratch coworker: new session starts instantly, no gate, no dialog", async ({ page }) => {
await page.goto("/");
await newDraftAs(page, /Ops Coworker/);
await expect(page.locator(".gate-overlay")).toHaveCount(0);
const box = page.getByPlaceholder(/Ask the coworker/);
await box.fill("hello there");
await page.getByRole("button", { name: "Send" }).click();
await expect(page.getByText(/Echo: hello there/)).toBeVisible();
await expect(page.getByTestId("send-folder-dialog")).toHaveCount(0);
});
test("gated coworker: send with no folder asks where to work; temp folder sends the message", async ({
page,
}) => {
await page.goto("/");
await newDraftAs(page, /Security Coworker/);
// No modal gate up front — the composer is live and the draft is composable.
await expect(page.locator(".gate-overlay")).toHaveCount(0);
await page.getByPlaceholder(/Ask the coworker/).fill("fix the tests");
await page.getByRole("button", { name: "Send" }).click();
const dlg = page.getByTestId("send-folder-dialog");
await expect(dlg).toBeVisible();
await expect(dlg.getByText("Where should Security Coworker work?")).toBeVisible();
await dlg.getByTestId("start-temp-folder").click();
// The message flies as soon as the choice lands — no second send click, and the local
// echo isn't duplicated by turn_start (the notice sits between them).
await expect(page.getByText(/Echo: fix the tests/)).toBeVisible();
await expect(page.locator(".main-scroll").getByText("fix the tests", { exact: true })).toHaveCount(1);
await expect(page.getByText("Temporary folder created · git initialized")).toBeVisible();
// The raw temp path never shows: header says "Temporary folder" + Save as project….
const sub = page.getByTestId("session-subtitle");
await expect(sub).toContainText("Security Coworker");
await expect(sub).toContainText("Temporary folder");
await expect(sub).not.toContainText("ow-temp");
await expect(page.getByTestId("save-as-project")).toBeVisible();
// One-time pick: the setup row left with the first message.
await expect(page.getByTestId("setup-row")).toHaveCount(0);
// A NEW session never inherits the temporary dir — the folder chip starts fresh.
await page.getByText("New session").first().click();
await expect(page.getByTestId("folder-chip")).toContainText("Choose folder");
});
test("gated coworker: Choose a folder… binds the picked project and sends", async ({ page }) => {
await page.goto("/");
await newDraftAs(page, /Security Coworker/);
await page.getByPlaceholder(/Ask the coworker/).fill("hello repo");
await page.getByRole("button", { name: "Send" }).click();
// Native pick is mocked server-side → /tmp/picked-folder.
await page.getByTestId("send-folder-dialog").getByRole("button", { name: "Choose a folder…" }).click();
await expect(page.getByText(/Echo: hello repo/)).toBeVisible();
await expect(page.getByTestId("session-subtitle")).toContainText("picked-folder");
await expect(page.getByTestId("save-as-project")).toHaveCount(0);
});
test("escape restores the draft instead of losing it", async ({ page }) => {
await page.goto("/");
await newDraftAs(page, /Security Coworker/);
const box = page.getByPlaceholder(/Ask the coworker/);
await box.fill("precious draft");
await page.getByRole("button", { name: "Send" }).click();
await expect(page.getByTestId("send-folder-dialog")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByTestId("send-folder-dialog")).toHaveCount(0);
await expect(box).toHaveValue("precious draft");
});
test("an explicit folder pick survives a coworker change; menu copy matches state", async ({
page,
}) => {
// Owner catch 2026-08-24 (v0.2.0 walkthrough): picking a folder and THEN picking the
// coworker silently reset the folder. The user's own chip pick must survive; only
// inherited folders (boot-resume, scratch) still clear on a coworker change.
await page.goto("/");
await page.getByText("New session").first().click();
// No folder yet — the menu's browse action reads "Choose a folder…", not "another".
await page.getByTestId("folder-chip").click();
const browseBtn = page.locator(".setup-menu").getByRole("button", { name: /Choose a(nother)? folder…/ });
await expect(browseBtn).toHaveText(/Choose a folder…/);
await browseBtn.click(); // native pick is mocked server-side → /tmp/picked-folder
await expect(page.getByTestId("folder-chip")).toContainText("picked-folder");
// With a folder bound, the same action offers "another".
await page.getByTestId("folder-chip").click();
await expect(
page.locator(".setup-menu").getByRole("button", { name: /Choose a(nother)? folder…/ }),
).toHaveText(/Choose another folder…/);
await page.mouse.click(10, 10); // scrim click closes the menu
// Re-target the draft to a folder-gated coworker — the pick survives…
await page.getByTestId("coworker-chip").click();
await page.locator(".setup-menu").getByRole("button", { name: /Security Coworker/ }).click();
await expect(page.getByTestId("folder-chip")).toContainText("picked-folder");
// …and the send goes straight through, no folder dialog.
await page.getByPlaceholder(/Ask the coworker/).fill("scan here");
await page.getByRole("button", { name: "Send" }).click();
await expect(page.getByText(/Echo: scan here/)).toBeVisible();
await expect(page.getByTestId("send-folder-dialog")).toHaveCount(0);
});

View File

@@ -0,0 +1,27 @@
// UX-037: Files — an explorer over the session's roots. Each root opens in the artifact
// viewer (breadcrumb "Files"), whose folder listings click through to subfolders and
// files. Artifacts stays the curated scratch-only surface beside it.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
test("Files lists the session roots and browses into a file", async ({ page }) => {
await page.goto("/");
await page.getByPlaceholder(/Ask the coworker/).fill("hello");
await page.getByRole("button", { name: "Send" }).click();
await expect(page.getByText(/Echo: hello/)).toBeVisible();
// Collapsed by default like every section (the More fold is gone — owner 2026-08-20).
await page.getByTestId("rail-toggle-files").click();
const row = page.getByTestId("files-root-row").first();
await expect(row).toContainText("scratch");
await expect(row).toContainText("read-write");
// Root → folder listing in the viewer, breadcrumb says Files.
await row.click();
await expect(page.getByTestId("artifact-folder")).toBeVisible();
await expect(page.locator(".artifact-title")).toContainText("Files");
// Drill into a file: the same viewer renders it.
await page.getByRole("button", { name: /notes\.md/ }).click();
await expect(page.locator(".artifact-md")).toContainText("hello from the explorer");
});

2268
surfaces/gui/e2e/fixtures.ts Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,31 @@
// The Gallery entry point was removed from Settings ▸ Coworkers (owner 2026-08-21) —
// coworkers install from GitHub / folder / zip. This file keeps the page-level
// delete flow (now on the coworker detail page, UX-035).
import { expect } from "@playwright/test";
import { test } from "./fixtures";
async function openPersonas(page) {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Settings", exact: true }).click();
await page.getByRole("button", { name: "Coworkers", exact: true }).click();
}
test("the Gallery entry point is gone from the Coworkers page", async ({ page }) => {
await openPersonas(page);
await expect(page.getByTestId("install-disclosure")).toBeVisible();
await expect(page.getByTestId("gallery-link")).toHaveCount(0);
});
test("delete: non-builtin personas removable after confirm; built-ins are not", async ({
page,
}) => {
// UX-035: delete moved off the list rows onto the coworker detail page.
await openPersonas(page);
await expect(page.getByText("Acme Notes")).toBeVisible();
await page.getByTestId("persona-configure-acme-notes").click();
await page.getByTestId("persona-delete").click();
await page.getByTestId("persona-delete-confirm").click();
// Back on the list, the row is gone (works signed out).
await expect(page.getByText("Acme Notes")).not.toBeVisible();
});

View File

@@ -0,0 +1,62 @@
// The Google Calendar detail page: gmail-parity multi-account (Default badge,
// Make default, per-account disconnect, direct one-click add — no modal).
import { expect } from "@playwright/test";
import { test } from "./fixtures";
async function openConnectors(page) {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Connectors", exact: true }).click();
}
async function signInAndConnectFirstAccount(page) {
await openConnectors(page);
await page.getByTestId("account-row").click();
await page.getByTestId("account-sign-in").click();
await expect(page.getByTestId("account-row")).toContainText("Rohit", { timeout: 10_000 });
// starts disconnected → Available row → one click (mock connects instantly)
await page
.getByTestId("connector-google_calendar")
.getByRole("button", { name: "Connect", exact: true })
.click();
await page.getByRole("button", { name: /Connect Google Calendar with one click/i }).click();
await page.keyboard.press("Escape");
await expect(page.getByTestId("connector-google_calendar")).toContainText("rohit@gmail.com", {
timeout: 10_000,
});
}
test("connect, then add a second account from the page; first stays default", async ({
page,
}) => {
await signInAndConnectFirstAccount(page);
await page.getByTestId("connector-google_calendar").click();
await expect(page.getByTestId("gcal-detail")).toBeVisible();
await page.getByTestId("add-account-btn").click();
const rohit = page.getByTestId("gcal-account-rohit@gmail.com");
const work = page.getByTestId("gcal-account-work@dlai.com");
await expect(work).toBeVisible({ timeout: 10_000 });
await expect(rohit).toContainText("Default");
await expect(work).not.toContainText("Default");
// list row summarizes the multi-account state
await page.getByTestId("connectors-breadcrumb").click();
await expect(page.getByTestId("connector-google_calendar")).toContainText("2 accounts");
});
test("Make default moves the badge; disconnecting the default repoints it", async ({
page,
}) => {
await signInAndConnectFirstAccount(page);
await page.getByTestId("connector-google_calendar").click();
await page.getByTestId("add-account-btn").click();
await expect(page.getByTestId("gcal-account-work@dlai.com")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("gcal-make-default-work@dlai.com").click();
await expect(page.getByTestId("gcal-account-work@dlai.com")).toContainText("Default");
await expect(page.getByTestId("gcal-account-rohit@gmail.com")).not.toContainText("Default");
await page.getByTestId("gcal-disconnect-work@dlai.com").click();
await expect(page.getByTestId("gcal-account-work@dlai.com")).toHaveCount(0);
await expect(page.getByTestId("gcal-account-rohit@gmail.com")).toContainText("Default");
});

View File

@@ -0,0 +1,107 @@
// The GitHub detail page (github-relay-spec §8): one group per App INSTALLATION
// with People / Waiting rows and a per-installation disconnect, add-installation
// via the header MODAL (One click | Manual), and the park → allow & deliver flow
// that admits a new sender login into that installation's allow-list.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
async function openGithubPage(page) {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Connectors", exact: true }).click();
await page.getByTestId("connector-github").click();
}
test("lists each installation as its own group with people and waiting rows", async ({
page,
}) => {
await openGithubPage(page);
const group = page.getByTestId("github-install-101");
await expect(group).toContainText("acme");
await expect(group).toContainText("selected repos"); // repo consent is GitHub-native
await expect(group).toContainText("@rohit-dev"); // logins ARE the readable identity
// the parked mention files under ITS installation, quoting the trigger
await expect(group).toContainText("@maya-dev");
await expect(group).toContainText("please take a look");
});
test("allow & deliver admits the sender into that installation's list", async ({
page,
}) => {
await openGithubPage(page);
await page.getByTestId("parked-allow-deliver-gh-pk1").click();
const group = page.getByTestId("github-install-101");
await expect(group).toContainText("@maya-dev"); // now a People chip
await expect(page.getByTestId("waiting-gh-pk1")).toHaveCount(0);
});
test("add installation opens the modal; signed in installs a second org", async ({
page,
}) => {
await openGithubPage(page);
await page.getByTestId("add-installation-btn").click();
const modal = page.getByTestId("add-connection-modal");
await expect(modal).toContainText("@ocw-agent App"); // one-click pane
await expect(modal).toContainText("Sign in to OpenWorker Cloud"); // signed out
// Manual PAT pane is right there too — both modes, one entry point
await modal.getByTestId("modal-pane-manual").click();
await expect(modal).toContainText("Personal access token");
await page.keyboard.press("Escape");
// sign in from the list's cloud strip, then install one-click
await page.getByTestId("connectors-breadcrumb").click();
await page.getByTestId("account-row").click();
await page.getByTestId("account-sign-in").click();
await expect(page.getByTestId("account-row")).toContainText("Rohit", { timeout: 10_000 });
await page.getByTestId("connector-github").click();
await page.getByTestId("add-installation-btn").click();
await page.getByTestId("modal-install-github-app").click();
// the mock completes the browser install instantly; the page's poll shows it
await expect(page.getByTestId("github-install-202")).toContainText("hooli", {
timeout: 10_000,
});
await expect(page.getByTestId("github-install-202")).toContainText("all repos");
await expect(page.getByTestId("github-install-101")).toBeVisible(); // existing stays
});
test("modal has ONE connect button and sends no flow — authorize-first lives in the broker", async ({
page,
}) => {
// The broker's default github flow user-authorizes first (links existing installations,
// redirects to the install page only when there are none) — so the modal's old
// "Already installed? Link it" secondary and its flow=authorize are gone.
await openGithubPage(page);
await page.getByTestId("connectors-breadcrumb").click();
await page.getByTestId("account-row").click();
await page.getByTestId("account-sign-in").click();
await expect(page.getByTestId("account-row")).toContainText("Rohit", { timeout: 10_000 });
await page.getByTestId("connector-github").click();
let flowSent: string | null = null;
await page.route("**/v1/connectors/github/connect-managed", async (route) => {
flowSent = (route.request().postDataJSON() || {}).flow ?? "";
await route.fulfill({ contentType: "application/json", body: JSON.stringify({ ok: true }) });
});
await page.getByTestId("add-installation-btn").click();
await expect(page.getByTestId("modal-link-github-install")).toHaveCount(0);
await page.getByTestId("modal-install-github-app").click();
await expect.poll(() => flowSent).toBe("");
});
test("disconnect removes one installation and keeps the rest", async ({ page }) => {
await openGithubPage(page);
// add a second installation first (signed-in one-click)
await page.getByTestId("connectors-breadcrumb").click();
await page.getByTestId("account-row").click();
await page.getByTestId("account-sign-in").click();
await expect(page.getByTestId("account-row")).toContainText("Rohit", { timeout: 10_000 });
await page.getByTestId("connector-github").click();
await page.getByTestId("add-installation-btn").click();
await page.getByTestId("modal-install-github-app").click();
await expect(page.getByTestId("github-install-202")).toBeVisible({ timeout: 10_000 });
await page.keyboard.press("Escape"); // the modal never auto-closes (by design)
await page.getByTestId("disconnect-install-202").click();
await expect(page.getByTestId("github-install-202")).toHaveCount(0);
await expect(page.getByTestId("github-install-101")).toBeVisible();
});

View File

@@ -0,0 +1,84 @@
// The Gmail detail page (M3.6 Step 3, UX-DECISIONS §21): multi-account with a
// Default badge, per-account disconnect, direct one-click add (no modal — Gmail
// has one connect mode), and the "Never show agents" filter lists.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
async function openConnectors(page) {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Connectors", exact: true }).click();
}
async function signInAndConnectFirstAccount(page) {
await openConnectors(page);
await page.getByTestId("account-row").click();
await page.getByTestId("account-sign-in").click();
await expect(page.getByTestId("account-row")).toContainText("Rohit", { timeout: 10_000 });
// gmail starts disconnected → Available row → modal → one click (mock connects instantly)
await page.getByTestId("connector-gmail").getByRole("button", { name: "Connect", exact: true }).click();
await page.getByRole("button", { name: /Connect Gmail with one click/i }).click();
await page.keyboard.press("Escape");
await expect(page.getByTestId("connector-gmail")).toContainText("rohit@gmail.com", {
timeout: 10_000,
});
}
test("connect, then add a second account from the page; first stays default", async ({
page,
}) => {
await signInAndConnectFirstAccount(page);
await page.getByTestId("connector-gmail").click();
await expect(page.getByTestId("gmail-detail")).toBeVisible();
await page.getByTestId("add-account-btn").click();
const rohit = page.getByTestId("gmail-account-rohit@gmail.com");
const work = page.getByTestId("gmail-account-work@dlai.com");
await expect(work).toBeVisible({ timeout: 10_000 });
await expect(rohit).toContainText("Default");
await expect(work).not.toContainText("Default");
// list row summarizes the multi-account state
await page.getByTestId("connectors-breadcrumb").click();
await expect(page.getByTestId("connector-gmail")).toContainText("2 accounts");
});
test("Make default moves the badge; disconnecting the default repoints it", async ({
page,
}) => {
await signInAndConnectFirstAccount(page);
await page.getByTestId("connector-gmail").click();
await page.getByTestId("add-account-btn").click();
await expect(page.getByTestId("gmail-account-work@dlai.com")).toBeVisible({ timeout: 10_000 });
await page.getByTestId("gmail-make-default-work@dlai.com").click();
await expect(page.getByTestId("gmail-account-work@dlai.com")).toContainText("Default");
await expect(page.getByTestId("gmail-account-rohit@gmail.com")).not.toContainText("Default");
await page.getByTestId("gmail-disconnect-work@dlai.com").click();
await expect(page.getByTestId("gmail-account-work@dlai.com")).toHaveCount(0);
await expect(page.getByTestId("gmail-account-rohit@gmail.com")).toContainText("Default");
});
test("Never show agents: sender + label chips round-trip", async ({ page }) => {
await signInAndConnectFirstAccount(page);
await page.getByTestId("connector-gmail").click();
const senders = page.getByTestId("gmail-filter-senders");
await senders.getByRole("textbox").fill("ceo@corp.com");
await senders.getByRole("textbox").press("Enter");
await expect(senders).toContainText("ceo@corp.com");
const labels = page.getByTestId("gmail-filter-labels");
await labels.getByRole("textbox").fill("Personal");
await labels.getByRole("textbox").press("Enter");
await expect(labels).toContainText("Personal");
// chips survive a reload (persisted through the PATCH route, re-read on load)
await page.reload();
await openConnectors(page);
await page.getByTestId("connector-gmail").click();
await expect(page.getByTestId("gmail-filter-senders")).toContainText("ceo@corp.com");
// remove round-trips too
await page.getByTestId("gmail-filter-senders").getByTitle("remove").click();
await expect(page.getByTestId("gmail-filter-senders")).not.toContainText("ceo@corp.com");
});

View File

@@ -0,0 +1,81 @@
// Google one-click paused pending CASA verification (owner ask 2026-07-22): the managed
// button parks with a "Coming soon" badge — pre-connect modal AND the connected page's
// add-account — while the manual token path stays fully live. The shared fixture keeps
// gmail unpaused (the cloud-machinery specs use it as their one-click subject), so this
// spec overrides the connectors payload per test, like automations-quickstart does.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
const GMAIL_BASE = {
name: "gmail",
title: "Gmail",
icon: "✉",
blurb: "Search, summarize, draft, and send email.",
about: "Search, summarize, and send over your Gmail.",
access: ["Reads and searches your mail."],
auth: "oauth",
two_way: false,
channels: false,
available: true,
brand_color: "#ea4335",
logo: "gmail",
fields: [
{ key: "access_token", label: "OAuth access token", secret: true, required: true, help: "", placeholder: "" },
],
instructions: [],
account: null,
allowed_users: [],
tools: [],
managed: true,
managed_paused: true,
managed_profile: false,
};
async function serveGmail(page, extra: Record<string, unknown>) {
await page.route("**/v1/connectors", (route) =>
route.fulfill({ json: { connectors: [{ ...GMAIL_BASE, connected: false, enabled: false, ...extra }] } }),
);
}
async function openConnectors(page) {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Connectors", exact: true }).click();
}
test("paused one-click: Coming soon badge in the connect modal, manual path alive", async ({
page,
}) => {
await serveGmail(page, {});
await openConnectors(page);
await page.getByTestId("connector-gmail").getByRole("button", { name: "Connect", exact: true }).click();
const soon = page.getByTestId("managed-coming-soon");
await expect(soon).toBeVisible();
await expect(soon).toBeDisabled();
await expect(soon).toContainText("Coming soon");
await expect(page.getByText("connect manually below for now")).toBeVisible();
// The manual token field is still right there.
await expect(page.getByText("OAuth access token")).toBeVisible();
});
test("paused one-click: connected page's add-account is parked too", async ({ page }) => {
await serveGmail(page, {
connected: true,
enabled: true,
account: "rohit@gmail.com",
accounts: [
{ email: "rohit@gmail.com", default: true, managed: true, scopes: "gmail", needs_reauth: false },
],
filters: { senders: [], labels: [] },
});
await openConnectors(page);
await page.getByTestId("connector-gmail").click();
await expect(page.getByTestId("gmail-detail")).toBeVisible();
const add = page.getByTestId("add-account-btn");
await expect(add).toBeDisabled();
await expect(add).toContainText("Coming soon");
// Existing accounts keep working and stay manageable.
await expect(page.getByTestId("gmail-account-rohit@gmail.com")).toContainText("Default");
});

View File

@@ -0,0 +1,93 @@
// The HubSpot detail page (M3.6 Step 4, UX-DECISIONS §21): multi-portal with
// Default/Sandbox/access tags, the add-modal with One click (read | write
// consent radios) | Manual private-app pills, and the hidden-fields denylist.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
async function openConnectors(page) {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Connectors", exact: true }).click();
}
async function signIn(page) {
await page.getByTestId("account-row").click();
await page.getByTestId("account-sign-in").click();
await expect(page.getByTestId("account-row")).toContainText("Rohit", { timeout: 10_000 });
}
test("connect via modal: access radios pick the consent tier; tags reflect it", async ({
page,
}) => {
await openConnectors(page);
await signIn(page);
// Available row → Connect → the two-pill modal with the access radios
await page.getByTestId("connector-hubspot").getByRole("button", { name: "Connect" }).click();
const modal = page.getByTestId("add-connection-modal");
await expect(modal.getByTestId("hubspot-access-read")).toBeChecked(); // read-only default
await expect(modal).toContainText("never delete");
await modal.getByTestId("hubspot-access-write").check();
await modal.getByTestId("modal-connect-hubspot").click();
await page.keyboard.press("Escape");
// the mock connects instantly; the row moves to Connected and navigates
await expect(page.getByTestId("connector-hubspot")).toContainText("Acme Inc", {
timeout: 10_000,
});
await page.getByTestId("connector-hubspot").click();
const row = page.getByTestId("hubspot-portal-111");
await expect(row).toContainText("Default");
await expect(page.getByTestId("hubspot-access-tag-111")).toContainText("read & write");
});
test("manual pane offers the private-app token (no duplicated one-click)", async ({
page,
}) => {
await openConnectors(page);
await page.getByTestId("connector-hubspot").getByRole("button", { name: "Connect" }).click();
const modal = page.getByTestId("add-connection-modal");
await modal.getByTestId("modal-pane-manual").click();
await expect(modal.getByPlaceholder("pat-…")).toBeVisible();
await expect(modal.getByTestId("managed-connect")).toHaveCount(0); // one-click lives on the other pill
});
test("second portal: sandbox tag, make-default, disconnect repoints", async ({ page }) => {
await openConnectors(page);
await signIn(page);
await page.getByTestId("connector-hubspot").getByRole("button", { name: "Connect" }).click();
await page.getByTestId("modal-connect-hubspot").click();
await page.keyboard.press("Escape");
await expect(page.getByTestId("connector-hubspot")).toContainText("Acme Inc", { timeout: 10_000 });
await page.getByTestId("connector-hubspot").click();
// add the sandbox portal from the page's header button
await page.getByTestId("add-portal-btn").click();
await page.getByTestId("modal-connect-hubspot").click();
await page.keyboard.press("Escape");
const sandbox = page.getByTestId("hubspot-portal-222");
await expect(sandbox).toContainText("Sandbox", { timeout: 10_000 });
await page.getByTestId("hubspot-make-default-222").click();
await expect(sandbox).toContainText("Default");
await page.getByTestId("hubspot-disconnect-222").click();
await expect(page.getByTestId("hubspot-portal-222")).toHaveCount(0);
await expect(page.getByTestId("hubspot-portal-111")).toContainText("Default");
});
test("hidden fields round-trip and read back normalized", async ({ page }) => {
await openConnectors(page);
await signIn(page);
await page.getByTestId("connector-hubspot").getByRole("button", { name: "Connect" }).click();
await page.getByTestId("modal-connect-hubspot").click();
await page.keyboard.press("Escape");
await expect(page.getByTestId("connector-hubspot")).toContainText("Acme Inc", { timeout: 10_000 });
await page.getByTestId("connector-hubspot").click();
const row = page.getByTestId("hubspot-hidden-fields");
await row.getByRole("textbox").fill("Salary");
await row.getByRole("textbox").press("Enter");
await expect(row).toContainText("salary"); // normalized lowercase from the PATCH echo
await row.getByTitle("remove").click();
await expect(row).not.toContainText("salary");
});

View File

@@ -0,0 +1,77 @@
import { test, expect } from "./fixtures";
// The Inbox (owner testing pass, 2026-07-03; §28 two-tab split 2026-07-12): Pending holds the
// kind chips (All/Approvals/Questions), persona filter chips (only with >1 persona holding
// items), and resolve-removes-card. Routing moved to the Configure tab (the former Connectors ▸
// Messaging routing page) — Pending's status line is read-only and links there; the old inline
// editor (the mirror setting's SECOND editor) is gone.
async function openInbox(page: import("@playwright/test").Page) {
await page.goto("/");
// §26: the fixtures seed pending items, so the account row's inbox chip is unlocked and
// pending — clicking it goes STRAIGHT to Inbox (the menu is the row's target, not the chip's).
await page.getByTestId("inbox-chip").click();
await expect(page.getByText("Approve: run_shell")).toBeVisible();
}
test("kind + persona filters narrow the pending list", async ({ page }) => {
await openInbox(page);
const question = "Which environment should I restart?";
await expect(page.getByText(question)).toBeVisible();
const filters = page.getByTestId("inbox-filters");
await filters.getByRole("button", { name: "Approvals" }).click();
await expect(page.getByText(question)).not.toBeVisible();
await expect(page.getByText("Approve: run_shell")).toBeVisible();
await filters.getByRole("button", { name: "Questions" }).click();
await expect(page.getByText("Approve: run_shell")).not.toBeVisible();
await expect(page.getByText(question)).toBeVisible();
// Persona chips render because two personas hold items; filtering to Ops hides the cowork item.
await filters.getByRole("button", { name: "All", exact: true }).click();
await filters.getByRole("button", { name: "Ops", exact: true }).click();
await expect(page.getByText("Approve: run_shell")).not.toBeVisible();
await expect(page.getByText(question)).toBeVisible();
});
test("resolving an approval removes its card; question options resolve on click", async ({ page }) => {
await openInbox(page);
await page.getByRole("button", { name: "Approve", exact: true }).click();
await expect(page.getByText("Approve: run_shell")).not.toBeVisible();
// Single-select question: clicking an option resolves immediately.
await page.getByRole("button", { name: "staging", exact: true }).click();
await expect(page.getByText("Which environment should I restart?")).not.toBeVisible();
await expect(page.getByText("Nothing pending.")).toBeVisible();
});
test("routing: Configure tab binds the mirror channel; Pending's status line follows", async ({
page,
}) => {
await openInbox(page);
const line = page.getByTestId("inbox-routing");
await expect(line).toContainText("Delivered here only");
// The status line is read-only — its Configure link lands on the Configure tab, which
// holds the ONE editor (the old inline editor was a duplicate of this card).
await page.getByTestId("inbox-route-configure").click();
const mirror = page.getByTestId("inbox-mirror-card");
await expect(mirror).toContainText("in-app Inbox only");
await mirror.getByPlaceholder("slack:C0123 or channel link").fill("slack:T1DL/C0777");
await mirror.getByRole("button", { name: "Set", exact: true }).click();
await expect(mirror).toContainText("slack:T1DL/C0777");
// Back on Pending, the line reflects the new target immediately.
await page.getByTestId("inbox-tab-pending").click();
await expect(line).toContainText("slack:T1DL/C0777");
await expect(line).toContainText("replies there resolve items here");
// Clearing (also on Configure) returns Pending to local-only delivery.
await page.getByTestId("inbox-tab-configure").click();
await mirror.getByRole("button", { name: "clear" }).click();
await expect(mirror).toContainText("in-app Inbox only");
await page.getByTestId("inbox-tab-pending").click();
await expect(line).toContainText("Delivered here only");
});

View File

@@ -0,0 +1,32 @@
// Owner-hit 2026-07-22: Stop mid-stream kept the partial visible — until the NEXT message's
// turn_start wiped it, because the partial only ever lived in the ephemeral streaming buffer
// (assistant_message is what promotes text into the transcript, and an interrupted turn never
// emits one). The fix flushes the buffer into a durable assistant item on interrupted/error.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
test("interrupted partial stream survives the next turn", async ({ page }) => {
await page.goto("/");
await page.getByText("Draft the launch note").first().click();
const box = page.getByPlaceholder(/Ask the coworker/);
await box.fill("stream the epic");
await box.press("Enter");
// Let a few deltas land, then stop the turn.
await expect(page.getByText("The epic scrolls ever onward").first()).toBeVisible({
timeout: 10_000,
});
await page.getByRole("button", { name: /Stop/ }).click();
await expect(page.getByText("Interrupted.").first()).toBeVisible({ timeout: 5_000 });
// The partial is still on screen after the stop…
await expect(page.getByText("The epic scrolls ever onward").first()).toBeVisible();
// …and — the regression — still there after the next turn starts and completes.
await box.fill("continue please");
await box.press("Enter");
await expect(page.getByText("Echo: continue please", { exact: false }).first()).toBeVisible({
timeout: 10_000,
});
await expect(page.getByText("The epic scrolls ever onward").first()).toBeVisible();
});

View File

@@ -0,0 +1,99 @@
// UX-033/034: custom MCP servers live on the Connectors page. "Add custom server"
// (top of page) opens the two-tab modal (Remote URL / JSON); added entries land in
// the "Custom · MCP" group with honest status chips (Testing… → Live / Error /
// Needs sign-in / Not tested) and a detail subpage with Test.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
async function openConnectors(page) {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Connectors", exact: true }).click();
}
test("remote URL add: probe flips the row to Live with tool count", async ({ page }) => {
await openConnectors(page);
await page.getByTestId("add-custom-server").click();
// URL tab is the default door; bad URL is caught before anything is added.
const modal = page.getByTestId("add-mcp-modal");
await modal.getByTestId("mcp-add-name").fill("notes");
await modal.getByTestId("mcp-add-url").fill("mcp.example.com/mcp");
await modal.getByRole("button", { name: "Add & test" }).click();
await expect(modal.getByText("Enter the server's full URL")).toBeVisible();
await modal.getByTestId("mcp-add-url").fill("https://mcp.example.com/mcp");
await modal.getByRole("button", { name: "Add & test" }).click();
// Adding lands STRAIGHT on the detail page (OPE-136: the connect-time tool
// review ceremony lives there) — the probe's status plays out in its header.
const detail = page.getByTestId("mcp-detail-notes");
await expect(detail).toContainText("Testing…");
await expect(detail).toContainText("Ready", { timeout: 10_000 });
await expect(detail).toContainText("6 tools");
// Back on the list, the row carries the same receipt.
await page.getByText(" Connectors").click();
await expect(page.getByTestId("mcp-row-notes")).toContainText("Ready");
});
test("guarded server: 401 → Needs sign-in chip → OAuth switch on the detail page", async ({
page,
}) => {
await openConnectors(page);
await page.getByTestId("add-custom-server").click();
const modal = page.getByTestId("add-mcp-modal");
await modal.getByTestId("mcp-add-name").fill("locked-crm");
await modal.getByTestId("mcp-add-url").fill("https://mcp.locked.example/mcp");
await modal.getByRole("button", { name: "Add & test" }).click();
// Adding lands on the detail page; the anonymous probe 401s there — chip and
// error excerpt on the same screen as the fix.
const detail = page.getByTestId("mcp-detail-locked-crm");
await expect(detail).toContainText("Needs sign-in", { timeout: 10_000 });
await expect(detail).toContainText("authentication required");
await detail.getByTestId("mcp-authfix-locked-crm").click();
await expect(detail).toContainText("Signing in…");
await expect(detail).toContainText("Ready", { timeout: 10_000 });
});
test("JSON tab adds stdio as Not tested; detail Test flips it to Live", async ({ page }) => {
await openConnectors(page);
await page.getByTestId("add-custom-server").click();
const modal = page.getByTestId("add-mcp-modal");
await modal.getByTestId("mcp-add-tab-json").click();
await modal
.locator("textarea")
.fill('{"files": {"command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem"]}}');
await modal.getByRole("button", { name: "Add", exact: true }).click();
// Adding lands on the detail page. A pasted stdio server is configured, not
// connected — the chip says so, right where Test can fix it.
const detail = page.getByTestId("mcp-detail-files");
await expect(detail).toContainText("Not tested");
await expect(detail).toContainText("stdio");
await detail.getByTestId("mcp-test-files").click();
await expect(detail).toContainText("Testing…");
await expect(detail).toContainText("Ready", { timeout: 10_000 });
await expect(detail).toContainText("6 tools");
// Remove from the detail page returns to the list without the row.
await detail.getByTestId("mcp-remove-files").click();
await expect(page.getByTestId("mcp-row-files")).toHaveCount(0);
});
test("the name field prefills from the URL's distinctive host label", async ({ page }) => {
await openConnectors(page);
await page.getByTestId("add-custom-server").click();
const modal = page.getByTestId("add-mcp-modal");
// Generic labels (mcp/api/data/www) are skipped; the first distinctive one wins.
await modal.getByTestId("mcp-add-url").fill("https://data.dlai.link/api/mcp");
await expect(modal.getByTestId("mcp-add-name")).toHaveValue("dlai");
// Never overwrite what the user typed.
await modal.getByTestId("mcp-add-name").fill("warehouse");
await modal.getByTestId("mcp-add-url").fill("https://mcp.linear.app/mcp");
await expect(modal.getByTestId("mcp-add-name")).toHaveValue("warehouse");
});

View File

@@ -0,0 +1,71 @@
// MCP-backed connectors (UX-DECISIONS §42): monday/asana/jira connect through the
// vendor's hosted MCP server via a fully LOCAL OAuth flow — one-click without any
// cloud sign-in — and agents get only the PINNED tool subset, surfaced on the
// connector detail page like any other curated tool set.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
async function openConnectors(page) {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Connectors", exact: true }).click();
}
test("monday: one-click MCP connect without cloud sign-in; card flips connected", async ({
page,
}) => {
await openConnectors(page);
// Signed OUT (fixtures default) — the MCP one-click needs no OpenWorker account.
await page
.getByTestId("connector-monday")
.getByRole("button", { name: "Connect" })
.click();
const modal = page.getByTestId("add-connection-modal");
await expect(modal).toBeVisible();
// Single-mode: no One click | Manual pills, no cloud sign-in gate — just the button.
await expect(modal.getByTestId("modal-pane-manual")).toHaveCount(0);
await expect(modal.getByTestId("inline-cloud-sign-in")).toHaveCount(0);
await expect(modal.getByText("sign-in runs entirely on this computer")).toBeVisible();
await modal.getByTestId("modal-mcp-one-click").click();
await expect(modal.getByText("Check your browser…")).toBeVisible();
// The mock flow completes instantly; the modal's poll closes it and the card flips.
await expect(page.getByTestId("add-connection-modal")).toHaveCount(0, {
timeout: 10_000,
});
await expect(page.getByTestId("connector-monday")).toContainText("Connected");
});
test("jira: two modes — MCP one-click pane plus the manual token form", async ({
page,
}) => {
await openConnectors(page);
// jira sits past the available-list fold.
await page.getByRole("button", { name: "show all" }).click();
await page
.getByTestId("connector-jira")
.getByRole("button", { name: "Connect" })
.click();
const modal = page.getByTestId("add-connection-modal");
// One click pane is the MCP flow (no cloud sign-in gate).
await expect(modal.getByTestId("modal-pane-one")).toBeVisible();
await expect(modal.getByTestId("modal-mcp-one-click")).toBeVisible();
// Manual keeps the existing Atlassian token fields.
await modal.getByTestId("modal-pane-manual").click();
await expect(modal.getByText("Atlassian site URL")).toBeVisible();
await expect(modal.getByText("API token")).toBeVisible();
});
test("monday detail page shows the pinned tool subset with approval badges", async ({
page,
}) => {
await openConnectors(page);
await page.getByTestId("connector-monday").click();
await expect(page.getByText("2 tools this connector adds")).toBeVisible();
await page.getByText("View", { exact: true }).click();
await expect(page.getByText("Read board", { exact: true })).toBeVisible();
await expect(page.getByText("Create item", { exact: true })).toBeVisible();
});

View File

@@ -0,0 +1,37 @@
// MCP OAuth quick-add (first server: Granola): the Custom · MCP group on the
// Connectors page offers a curated Connect card; connecting adds the server, kicks
// off the browser sign-in (Signing in…), and the poll flips the row to Live.
// Sign out (detail page) returns it to Needs sign-in.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
async function openConnectors(page) {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Connectors", exact: true }).click();
}
test("granola: quick-add card → sign-in flow → Live → sign out", async ({ page }) => {
await openConnectors(page);
// Curated OFFER renders among the Available connectors while granola isn't
// configured (never inside Custom · MCP — a row there means a server you own).
const preset = page.getByTestId("mcp-preset-granola");
await expect(preset).toContainText("Granola");
await expect(preset).toContainText("Meeting notes");
// Connect: adds the server, starts the browser sign-in, and lands STRAIGHT on
// the detail page (OPE-136: the connect-time tool review ceremony lives there).
await preset.getByRole("button", { name: "Connect" }).click();
const detail = page.getByTestId("mcp-detail-granola");
await expect(detail).toContainText("Signing in…");
// The status poll flips the mock to connected with its 6 tools.
await expect(detail).toContainText("Ready", { timeout: 10_000 });
await expect(detail).toContainText("6 tools");
// Sign out forgets tokens; the chip needs sign-in again.
await detail.getByTestId("mcp-signout-granola").click();
await expect(detail).toContainText("Needs sign-in");
await expect(detail.getByTestId("mcp-signin-granola")).toBeVisible();
});

View File

@@ -0,0 +1,33 @@
// Model-layer roadmap item 3 (2026-07-22): the model picker stays actionable for the
// session's whole life (supersedes the 2026-07-04 lock that hid it after the first turn).
// A mid-session switch drops a persisted info marker into the transcript, and later
// messages ride the new model.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
test("mid-session model switch shows the marker and later turns use the new model", async ({
page,
}) => {
await page.goto("/");
await page.getByText("Draft the launch note").first().click();
const box = page.getByPlaceholder(/Ask the coworker/);
await box.fill("hello there");
await box.press("Enter");
await expect(page.getByText("Echo: hello there", { exact: false }).first()).toBeVisible();
// The picker is still in the composer after the first turn (the old lock hid it).
const picker = page.locator(".dd").filter({ hasText: "Claude Opus 4.8" });
await expect(picker).toBeVisible();
await picker.locator(".pill").click();
await page.locator(".dd-item").filter({ hasText: "GPT-5.5" }).click();
// The switch marker lands in the transcript…
await expect(page.getByText(/Model switched to gpt-5.5/).first()).toBeVisible();
// …and the next message carries the new model (the fixture echoes it back).
await box.fill("after the switch");
await box.press("Enter");
await expect(
page.getByText("Echo: after the switch [model=gpt-5.5]", { exact: false }).first(),
).toBeVisible();
});

View File

@@ -0,0 +1,51 @@
// Left-nav polish (§20): collapse (⌘B / brand button → reveal button docks it back) and the
// RECENT-header group/filter popover (Group by Persona↔Chronological, Filter by coworker).
import { expect } from "@playwright/test";
import { test } from "./fixtures";
test("collapse hides the sidebar and reclaims the width; reveal button docks it back", async ({
page,
}) => {
await page.goto("/");
const app = page.locator(".app");
await expect(page.locator(".sidebar")).toBeVisible();
// Collapse via the brand button.
await page.getByRole("button", { name: "Collapse sidebar" }).click();
await expect(app).toHaveClass(/nav-collapsed/);
// The floating reveal affordance appears; clicking it docks the nav back.
const reveal = page.getByRole("button", { name: "Show sidebar" });
await expect(reveal).toBeVisible();
await reveal.click();
await expect(app).not.toHaveClass(/nav-collapsed/);
});
test("⌘B toggles the sidebar collapse", async ({ page }) => {
await page.goto("/");
const app = page.locator(".app");
await page.keyboard.press("Meta+b");
await expect(app).toHaveClass(/nav-collapsed/);
await page.keyboard.press("Meta+b");
await expect(app).not.toHaveClass(/nav-collapsed/);
});
test("RECENT header group/filter popover: switch grouping + see coworker filters", async ({
page,
}) => {
await page.goto("/");
const header = page.getByTestId("recent-header");
await expect(header).toContainText("Recent");
await header.getByRole("button", { name: "Group and filter conversations" }).click();
const menu = page.getByTestId("group-filter-menu");
await expect(menu).toContainText("Group by");
await expect(menu).toContainText("Filter by coworker");
// Switch to Chronological → the persona accordion collapses into a flat list (the "OpenWorker"
// persona group header is no longer a row; sessions list directly).
await menu.getByText("Chronological").click();
await expect(menu.getByText("Chronological").locator("xpath=..")).toContainText("✓");
// Filter-by-coworker checkboxes are present (none checked by default → all shown).
await expect(menu).toContainText("None checked shows all.");
});

View File

@@ -0,0 +1,156 @@
// First-run onboarding (UX-DECISIONS §24 → §29 → §39): model → your tools → go.
// §39: step 1 is a provider GALLERY (cards wear their own state; a card opens its key
// form inside a fixed-height swap region; Test verifies, SAVES, and returns) and step 2
// is a two-state tools page (why-paragraph + sign-in → mini connector gallery with live
// one-click connects). Entered here via the REPLAY path (Settings ▸ Appearance ▸ "Run
// setup again") — which is itself under test.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
async function openOnboarding(page) {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByTestId("account-menu").getByRole("button", { name: "Settings" }).click();
await page.getByRole("button", { name: "Run setup again" }).click();
await expect(page.getByTestId("ob-step-model")).toBeVisible();
}
test("provider gallery: cards wear their state; Next arms off stored credentials", async ({
page,
}) => {
await openOnboarding(page);
// Every card carries its own status with zero clicks (the 2026-07-16 confusion —
// "is OpenAI already connected?" — is answered by the gallery itself).
await expect(page.getByTestId("ob-provider-openai")).toContainText("✓ Connected");
await expect(page.getByTestId("ob-provider-anthropic")).toContainText("✓ Connected");
await expect(page.getByTestId("ob-provider-zai")).toContainText("Not set up");
await expect(page.getByTestId("ob-provider-ollama")).toContainText("No key needed");
// Recognition-first order: anthropic before openai before the OpenAI-compat tail.
const names = await page
.getByTestId("ob-provider-gallery")
.locator("[data-testid^=ob-provider-]")
.evaluateAll((els) => els.map((e) => e.getAttribute("data-testid")));
expect(names.indexOf("ob-provider-anthropic")).toBeLessThan(names.indexOf("ob-provider-openai"));
expect(names.indexOf("ob-provider-openai")).toBeLessThan(names.indexOf("ob-provider-zai"));
// A configured provider already arms Next — no form visit required.
await expect(page.getByTestId("ob-continue")).toBeEnabled();
await page.getByTestId("ob-continue").click();
await expect(page.getByTestId("ob-step-tools")).toBeVisible();
});
test("key form: Test verifies, saves, and returns to the gallery with the ✓", async ({
page,
}) => {
await openOnboarding(page);
await page.getByTestId("ob-provider-zai").click();
// The header stays put (§39 fixed frame): the welcome headline is still on screen.
await expect(page.getByRole("heading", { name: "Welcome to OpenWorker" })).toBeVisible();
// Optional endpoint is a quiet disclosure with no explainer copy (owner call 2026-07-18).
await expect(page.getByTestId("ob-field-base_url")).toHaveCount(0);
await page.getByTestId("ob-endpoint-link").click();
await expect(page.getByTestId("ob-field-base_url")).toHaveValue(/api\.z\.ai/);
// Bad key: the error is a line, not a navigation.
await page.getByTestId("ob-field-api_key").fill("bad-key");
await page.getByTestId("ob-test").click();
await expect(page.getByText("Invalid API key.")).toBeVisible();
// Good key: state lands IN the field ("✓ Tested & saved" pill), then the form
// auto-returns to the gallery where the Z AI card now wears its ✓.
await page.getByTestId("ob-field-api_key").fill("zk-good");
await page.getByTestId("ob-test").click();
await expect(page.getByTestId("ob-saved-pill")).toBeVisible();
await expect(page.getByTestId("ob-provider-zai")).toContainText("✓ Connected", {
timeout: 5_000,
});
await expect(page.getByTestId("ob-continue")).toBeEnabled();
});
test("key form: revisiting a connected provider shows the in-field saved state; drafts survive switching", async ({
page,
}) => {
await openOnboarding(page);
// Revisit a configured provider: green in-field pill + masked placeholder — the old
// empty-password-field-reads-as-not-set-up trap (owner complaint 2026-07-16) is gone.
await page.getByTestId("ob-provider-openai").click();
await expect(page.getByTestId("ob-saved-pill")).toBeVisible();
await expect(page.getByTestId("ob-field-api_key")).toHaveAttribute("placeholder", "••••••••");
// Typed-but-unsaved input survives a peek at another provider (drafts).
await page.getByTestId("ob-back").click();
await page.getByTestId("ob-provider-zai").click();
await page.getByTestId("ob-field-api_key").fill("zk-draft");
await page.getByTestId("ob-back").click();
await page.getByTestId("ob-provider-openai").click();
await expect(page.getByTestId("ob-saved-pill")).toBeVisible();
await page.getByTestId("ob-back").click();
await page.getByTestId("ob-provider-zai").click();
await expect(page.getByTestId("ob-field-api_key")).toHaveValue("zk-draft");
// Next from a dirty form auto-verifies and saves first (2026-07-12: no hidden
// Test-then-Continue two-step), then advances.
await page.getByTestId("ob-field-api_key").fill("zk-good");
await page.getByTestId("ob-continue").click();
await expect(page.getByTestId("ob-step-tools")).toBeVisible();
});
test("tools page: sign-in morphs the page into the connector gallery; a card connects one-click", async ({
page,
}) => {
await openOnboarding(page);
await page.getByTestId("ob-continue").click();
await expect(page.getByTestId("ob-step-tools")).toBeVisible();
// Pre-sign-in (§41): the benefit rows are already there (no Connect buttons yet),
// the combined Google row says Coming soon, the band asks for sign-in, and the one
// footer button is the quiet "Continue without sign-in".
await expect(page.getByText("Chat can only advise")).toBeVisible();
await expect(page.getByTestId("ob-tool-outlook")).toContainText("Stay on top of email");
await expect(page.getByTestId("ob-tool-outlook").getByRole("button")).toHaveCount(0);
await expect(page.getByTestId("ob-tool-attio")).toContainText("Track every relationship");
await expect(page.getByTestId("ob-tool-google-soon")).toContainText("Coming soon");
await expect(page.getByText("Sign in for one-click connections")).toBeVisible();
await expect(page.getByTestId("ob-tools-skip")).toContainText("Continue without sign-in");
// Sign-in lands out-of-band; the band's SLOT stays put and flips to the congrats
// (zero layout shift), and every row grows its Connect pill.
await page.getByTestId("ob-cloud-signin").click();
await expect(page.getByTestId("ob-tools-signedin")).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId("ob-tools-signedin")).toContainText("Youre signed in");
await expect(
page.getByTestId("ob-tool-attio").getByRole("button", { name: "Connect" }),
).toBeVisible();
await expect(page.getByTestId("ob-tool-google-soon").getByRole("button")).toHaveCount(0);
// One-click connect: the consent completes in the (mock) browser; the poll flips the
// row to ✓ Connected. Next was armed the whole time — connecting is optional.
await page.getByTestId("ob-tool-outlook").getByRole("button", { name: "Connect" }).click();
await expect(page.getByTestId("ob-tool-outlook")).toContainText("✓ Connected", {
timeout: 10_000,
});
await expect(page.getByTestId("ob-continue-tools")).toBeEnabled();
await page.getByTestId("ob-continue-tools").click();
// Done step: the automation CTA lands on the Automations quickstart.
await expect(page.getByTestId("ob-step-done")).toBeVisible();
await page.getByTestId("ob-cta-automation").click();
await expect(page.getByTestId("onboarding")).toHaveCount(0);
await expect(page.getByRole("heading", { name: "Automations" })).toBeVisible();
});
test("tools page skips cleanly; Start working lands in a session with the panel open", async ({
page,
}) => {
await openOnboarding(page);
await page.getByTestId("ob-continue").click();
await page.getByTestId("ob-tools-skip").click();
await expect(page.getByTestId("ob-step-done")).toBeVisible();
await page.getByTestId("ob-start").click();
await expect(page.getByTestId("onboarding")).toHaveCount(0);
// §32: "Start working" lands with the rail's Access section expanded (the drawer is gone).
await expect(page.getByRole("region", { name: "Session access" })).toBeVisible();
});

View File

@@ -0,0 +1,88 @@
import { test, expect } from "./fixtures";
// Regression for the invisible-after-install bug (2026-07-03): enabling a persona in
// Settings ▸ Personas must surface it EVERYWHERE without a reload — the New-Session picker and
// the grouped sidebar — via the PERSONAS_CHANGED event (and backend enable-implies-surface).
test("enabling an installed persona surfaces it in picker + sidebar without reload", async ({
page,
}) => {
await page.goto("/");
const sidebar = page.locator(".sidebar");
// Disabled install: absent from the composer's coworker picker and the grouped sidebar.
await page.getByText("New session").first().click();
await page.getByTestId("coworker-chip").click();
const menu = page.locator(".setup-menu");
await expect(menu).toBeVisible();
await expect(menu.getByText("Acme Notes")).toHaveCount(0);
await page.locator(".fixed.inset-0.z-20").click({ position: { x: 5, y: 5 } }); // close via backdrop (menu sits over center)
await expect(sidebar.getByText("Acme Notes")).toHaveCount(0);
// Enable it on the Coworkers page.
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Settings", exact: true }).click();
await page.getByRole("button", { name: "Coworkers", exact: true }).click();
const row = page.locator(".divide-y > div").filter({ hasText: "Acme Notes" });
// Controlled checkbox: the DOM state flips only after the POST round-trip, so click + expect
// (a plain .check() asserts the state synchronously and fails).
const enabled = row.getByRole("switch");
await enabled.click();
await expect(enabled).toBeChecked();
// No reload: the sidebar group and the picker both pick it up via PERSONAS_CHANGED.
await expect(sidebar.getByText("Acme Notes")).toBeVisible();
await page.getByText("New session").first().click();
await page.getByTestId("coworker-chip").click();
await expect(page.locator(".setup-menu").getByText("Acme Notes")).toBeVisible();
});
// Disable-archives (§18): disabling a persona archives its conversations, so the confirm must
// interpose when there's something to archive — and only then. The sidebar section disappears
// with the persona (its sessions are archived, so the never-orphan rule no longer holds it).
test("disabling a persona with conversations asks first, then archives them", async ({
page,
}) => {
await page.goto("/");
const sidebar = page.locator(".sidebar");
await expect(sidebar.getByText("Ops", { exact: true })).toBeVisible();
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Settings", exact: true }).click();
await page.getByRole("button", { name: "Coworkers", exact: true }).click();
// Ops is ships:false — it lives in the collapsed "Not in this release" group.
await page.getByTestId("unshipped-disclosure").click();
const row = page.locator(".divide-y > div").filter({ hasText: "Ops Coworker" });
const enabled = row.getByRole("switch");
// Unchecking only ARMS the confirm — the flag must not flip yet.
await enabled.click();
const warning = page.getByTestId("persona-disable-warning-ops");
await expect(warning).toContainText("archives its 1 conversation");
await expect(enabled).toBeChecked();
// Backing out leaves everything as it was.
await page.getByRole("button", { name: "Keep enabled" }).click();
await expect(warning).toHaveCount(0);
await expect(enabled).toBeChecked();
// Arm again and confirm: persona disables, its section leaves the sidebar without a reload.
await enabled.click();
await page.getByTestId("persona-disable-confirm-ops").click();
await expect(enabled).not.toBeChecked();
await expect(sidebar.getByText("Ops", { exact: true })).toHaveCount(0);
});
test("disabling a persona with no conversations skips the confirm", async ({ page }) => {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Settings", exact: true }).click();
await page.getByRole("button", { name: "Coworkers", exact: true }).click();
// Security ships enabled and has no conversations in the fixtures (Code now ships
// disabled, so it can't exercise the disable path).
const row = page.locator(".divide-y > div").filter({ hasText: "Security Coworker" });
const enabled = row.getByRole("switch");
await enabled.click();
await expect(page.getByTestId("persona-disable-warning-security")).toHaveCount(0);
await expect(enabled).not.toBeChecked();
});

View File

@@ -0,0 +1,67 @@
import { test, expect } from "./fixtures";
// UX-044: the composer "+" menu's session section — project-memory/board bindings.
// Guards: the two labeled sections, the radio submenu (derived label rules, MRU,
// bound tag), swap-binding round trip, board's "none" row, and naming the current
// project. Bindings are PROJECT memory only — global memory never appears here.
async function openAttach(page: import("@playwright/test").Page) {
await page.goto("/");
await page.getByText("Draft the launch note").first().click();
await page.getByRole("button", { name: "Attach" }).click();
}
test("attach menu: two sections, memory submenu with derived + named rows", async ({ page }) => {
await openAttach(page);
await expect(page.getByText("This message")).toBeVisible();
await expect(page.getByText("This session")).toBeVisible();
await expect(page.getByRole("button", { name: "Photo or image" })).toBeVisible();
await page.getByRole("button", { name: "Project memory" }).click();
const menu = page.getByTestId("project-menu-memory");
// Derived row: folder form, trimmed to the last 3 segments, tagged.
await expect(menu.getByText("…/ro4d/demo-universe/notes")).toBeVisible();
await expect(menu.getByText("this folder")).toBeVisible();
// Named rows (MRU), no filter under 6, the two actions.
await expect(menu.getByText("openworker")).toBeVisible();
await expect(menu.getByText("personal-ops")).toBeVisible();
await expect(menu.getByPlaceholder("Filter…")).toHaveCount(0);
await expect(menu.getByText("Name current memory…")).toBeVisible();
await expect(menu.getByText("View & edit…")).toBeVisible();
});
test("binding swap round-trips and closes the menu", async ({ page }) => {
await openAttach(page);
await page.getByRole("button", { name: "Project memory" }).click();
await page.getByTestId("project-menu-memory").getByText("openworker").click();
// Menu closed on success.
await expect(page.getByTestId("project-menu-memory")).toHaveCount(0);
// Reopen: the binding shows as bound.
await page.getByRole("button", { name: "Attach" }).click();
await page.getByRole("button", { name: "Project memory" }).click();
const menu = page.getByTestId("project-menu-memory");
await expect(menu.getByText("bound")).toBeVisible();
});
test("board submenu has a none row and its own names", async ({ page }) => {
await openAttach(page);
await page.getByRole("button", { name: "Board", exact: true }).click();
const menu = page.getByTestId("project-menu-board");
await expect(menu.getByText("none")).toBeVisible();
await expect(menu.getByText("aicreator-ops")).toBeVisible();
// Board naming exists; memory's View & edit does not.
await expect(menu.getByText("Name current board…")).toBeVisible();
await expect(menu.getByText("View & edit…")).toHaveCount(0);
});
test("naming the current project adds it to the named list", async ({ page }) => {
await openAttach(page);
await page.getByRole("button", { name: "Project memory" }).click();
await page.getByTestId("project-menu-memory").getByText("Name current memory…").click();
const input = page.getByPlaceholder("Name this memory…");
await input.fill("my-notes");
await input.press("Enter");
await expect(page.getByTestId("project-menu-memory").getByText("my-notes")).toBeVisible();
});

View File

@@ -0,0 +1,95 @@
// Settings ▸ Models key flows on the shared provider gallery (§39 components, UX-021 page):
// bad key fails in place, a passing Test auto-saves and slides home to the gallery where the
// card wears its ✓. Providers are seeded in three states (OpenAI configured+used, Anthropic
// configured-unused, Z AI unconfigured w/ a prefilled endpoint behind the disclosure). The
// mock's /verify fails on a key containing "bad"; POST /v1/providers flips `configured`.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
async function openModels(page) {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Settings", exact: true }).click();
await page.getByRole("button", { name: "Models", exact: true }).click();
await expect(page.getByTestId("set-provider-openai")).toBeVisible();
}
test("Test with a bad key fails in place; a good key saves and returns to the gallery", async ({
page,
}) => {
await openModels(page);
await page.getByTestId("set-provider-zai").click();
await page.getByTestId("set-field-api_key").fill("sk-bad-key");
await page.getByTestId("set-test").click();
await expect(page.getByText("Invalid API key.")).toBeVisible();
// A good key: Test verifies AND saves (§39) — the in-field pill confirms, then the form
// slides home and the card wears its ✓.
await page.getByTestId("set-field-api_key").fill("sk-glm-realkey");
await page.getByTestId("set-test").click();
await expect(page.getByTestId("set-saved-pill")).toContainText("Tested & saved");
await expect(page.getByTestId("set-provider-zai")).toContainText("✓ Connected", {
timeout: 5_000,
});
// State-restore regression (owner catch 2026-07-19): revisiting the just-saved provider
// must show the masked placeholder + saved pill — never the typed key restored as a draft
// (the auto-return used to stash the saved key and replay it on the next open).
await page.getByTestId("set-provider-zai").click();
await expect(page.getByTestId("set-field-api_key")).toHaveValue("");
await expect(page.getByTestId("set-field-api_key")).toHaveAttribute("placeholder", "••••••••");
await expect(page.getByTestId("set-saved-pill")).toContainText("Tested & saved");
});
test("a configured provider's form opens with the saved state, no plaintext key", async ({
page,
}) => {
await openModels(page);
await page.getByTestId("set-provider-openai").click();
// Stored credentials show as the in-field saved pill + masked placeholder — never the key.
await expect(page.getByTestId("set-saved-pill")).toContainText("Tested & saved");
await expect(page.getByTestId("set-field-api_key")).toHaveValue("");
await expect(page.getByTestId("set-field-api_key")).toHaveAttribute("placeholder", "••••••••");
});
test("non-secret fields blur-save on a configured provider (ollama endpoint)", async ({
page,
}) => {
// Owner-hit 2026-07-23 (as the thinking-budget field, since folded into a default):
// the Test button was the form's only save path — typing into a non-secret field and
// leaving Settings silently discarded it. Blur now saves.
await openModels(page);
await page.getByTestId("set-provider-ollama").click();
const endpoint = page.getByTestId("set-field-base_url");
await endpoint.fill("http://127.0.0.1:9999");
await endpoint.blur();
await expect(page.getByTestId("set-field-saved-base_url")).toBeVisible();
// Leave and come back: the value survived (served from the provider's stored values).
await page.getByTestId("set-back").click();
await page.getByTestId("set-provider-ollama").click();
await expect(page.getByTestId("set-field-base_url")).toHaveValue("http://127.0.0.1:9999");
});
test("the subscription provider signs in from the browser flow, no key form", async ({
page,
}) => {
await openModels(page);
const card = page.getByTestId("set-provider-openai-codex");
await expect(card).toContainText("Sign in with your plan");
await card.click();
// No key fields — the pane is the sign-in button (plus the blurb).
await expect(page.getByTestId("set-field-api_key")).toHaveCount(0);
await page.getByTestId("set-oauth-signin").click();
// The mock completes the flow on the first poll; the pane flips to signed in.
await expect(page.getByTestId("set-oauth-account")).toContainText("rohit@example.com");
// Sign out returns the pane (and the gallery card) to the signed-out state.
await page.getByTestId("set-oauth-signout").click();
await expect(page.getByTestId("set-oauth-signin")).toBeVisible();
await page.getByTestId("set-back").click();
await expect(card).toContainText("Sign in with your plan");
});

View File

@@ -0,0 +1,45 @@
// UX-038 follow-up (owner ruling 2026-08-21): the right rail starts hidden and the
// topbar toggle's choice survives a restart. Deep links (artifact chips) force-show
// transiently without overwriting the stored preference.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
async function clearRailPref(page: import("@playwright/test").Page) {
await page.goto("/");
await page.evaluate(() => {
localStorage.setItem("ocw-e2e-rail-default", "1"); // opt out of the fixture seed
localStorage.removeItem("coworker:rail-hidden:v1");
});
await page.reload();
}
test("rail is hidden by default; the toggle persists across restarts", async ({ page }) => {
await clearRailPref(page);
await expect(page.getByTestId("rail-toggle-artifacts")).toHaveCount(0);
// Show it — the choice must survive a reload ("restart").
await page.getByRole("button", { name: "Show side panel" }).click();
await expect(page.getByTestId("rail-toggle-artifacts")).toBeVisible();
await page.reload();
await expect(page.getByTestId("rail-toggle-artifacts")).toBeVisible();
// Hide it — that persists too.
await page.getByRole("button", { name: "Hide side panel" }).click();
await page.reload();
await expect(page.getByTestId("rail-toggle-artifacts")).toHaveCount(0);
});
test("an artifact chip force-shows the rail without overwriting the hidden preference", async ({ page }) => {
await clearRailPref(page);
// "show the report" makes the fixture echo carry an [artifact:] chip.
await page.getByPlaceholder(/Ask the coworker/).fill("show the report");
await page.getByRole("button", { name: "Send" }).click();
// The transcript's artifact chip opens the viewer even though the rail is hidden.
await page.getByTestId("artifact-chip").click();
await expect(page.getByTestId("artifact-frame")).toBeVisible();
// The stored preference is untouched: a reload starts hidden again.
await page.reload();
await expect(page.getByTestId("rail-toggle-artifacts")).toHaveCount(0);
});

View File

@@ -0,0 +1,31 @@
// Model-layer roadmap item 4 (2026-07-22): reasoning traces. Live turn shows a quiet
// pulsing "Thinking…" disclosure that streams the trace; once the message finalizes the
// trace folds into a collapsed "Thought process" disclosure on the answer bubble.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
test("thinking streams live, then persists as a collapsed disclosure on the answer", async ({
page,
}) => {
await page.goto("/");
await page.getByText("Draft the launch note").first().click();
const box = page.getByPlaceholder(/Ask the coworker/);
await box.fill("think hard about this");
await box.press("Enter");
// Live phase: the Thinking… block is up while deltas tick in; expanding shows the trace.
await expect(page.getByText("Thinking…").first()).toBeVisible({ timeout: 10_000 });
await page.getByTestId("thinking-toggle").click();
await expect(page.getByTestId("thinking-body")).toContainText("Weighing options.");
// Finalized: the answer bubble carries a collapsed "Thought process" disclosure.
await expect(page.getByText("Decision made.").first()).toBeVisible({ timeout: 10_000 });
await expect(page.getByText("Thinking…")).toHaveCount(0);
const toggle = page.getByTestId("thinking-toggle");
await expect(toggle).toHaveText(/Thought process/);
await expect(page.getByTestId("thinking-body")).toHaveCount(0); // collapsed by default
await toggle.click();
await expect(page.getByTestId("thinking-body")).toContainText(
"Weighing options. Comparing tradeoffs. Settling it.",
);
});

View File

@@ -0,0 +1,61 @@
// §8.4 breaker surfacing (owner ask 2026-08-24): when the Auto-Approve reviewer pauses
// itself after 5 straight denials, the transcript gets a notice AND the composer's mode
// chip says "· paused" — quietly, until the turn ends or an ask_user answer resets it.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
test("reviewer pause shows a transcript notice and marks the mode chip", async ({ page }) => {
await page.goto("/");
await page.getByText("Draft the launch note").first().click();
// Switch the session into Auto-approve (entry gated on the settings flag).
await page.getByRole("button", { name: "Mode", exact: true }).click();
await page.getByTestId("mode-menu").getByText("Auto-approve").click();
const box = page.getByPlaceholder(/Ask the coworker/);
await box.fill("trip the reviewer");
await box.press("Enter");
// The tripping deny carries the pause: notice inline, "· paused" on the chip.
await expect(page.getByText(/Auto-approve is paused for the rest of this turn/)).toBeVisible();
await expect(page.getByTestId("mode-paused")).toBeVisible();
await expect(page.getByRole("button", { name: "Mode", exact: true })).toContainText("paused");
});
test("an unsure escalation shows the reviewer's hesitation on the card", async ({ page }) => {
await page.goto("/");
await page.getByText("Draft the launch note").first().click();
const box = page.getByPlaceholder(/Ask the coworker/);
await box.fill("run an unsure tool");
await box.press("Enter");
const note = page.getByTestId("approval-reviewer-unsure");
await expect(note).toBeVisible();
await expect(note).toContainText("reviewer wasn\u2019t sure: This runs a newly created script");
});
test("mode notices: full explainer once, one-line markers after", async ({ page }) => {
await page.goto("/");
await page.getByText("Draft the launch note").first().click();
const pickMode = async (label: string) => {
await page.getByRole("button", { name: "Mode", exact: true }).click();
await page.getByTestId("mode-menu").getByText(label, { exact: false }).first().click();
};
// First entry into Auto-approve: the full (new, shorter) explainer.
await pickMode("Auto-approve");
await expect(page.getByText("Auto-approve is on.")).toBeVisible();
await expect(
page.getByText(/uses a model to let routine actions through without asking/),
).toBeVisible();
// Later switches: one-line markers only — the banner never repeats.
await pickMode("Ask for approval");
await expect(page.getByText("Ask for approval is on.")).toBeVisible();
await pickMode("Auto-approve");
await expect(page.getByText("Auto-approve is on.")).toHaveCount(2); // title + marker
await expect(
page.getByText(/uses a model to let routine actions through without asking/),
).toHaveCount(1);
});

View File

@@ -0,0 +1,51 @@
// Guards the per-session directory RO/RW gate (§ roots), which since §32 lives in the rail's
// Access section under "Folders" (folder access is standing session config, not per-message
// attachment — the composer's folder popover is gone). The section lists the primary writable
// workspace, and adding a folder is gated read-only by default with an explicit "Allow writes"
// opt-in.
import { test, expect } from "./fixtures";
test("working directories: add folders with the read-only / read-write gate", async ({ page }) => {
await page.goto("/");
await page.getByText("Draft the launch note").first().click();
// Expand the rail's Access section.
await page.getByTestId("access-toggle").click();
const dirs = page.getByTestId("drawer-directories");
await expect(dirs.getByText("Folders")).toBeVisible();
// The primary is the writable scratch workspace (Cowork shows it as "Temporary folder").
await expect(dirs.getByText("Temporary folder")).toBeVisible();
// Add a folder — the gate defaults to read-only (Allow writes OFF). The Browse button works
// in the BROWSER too (sidecar-opened native picker; owner report 2026-07-04).
await dirs.getByRole("button", { name: "Give access to a folder" }).click();
await dirs.getByRole("button", { name: "Choose location" }).click();
await expect(dirs.getByPlaceholder(/Choose or paste a folder path/)).toHaveValue(
"/tmp/picked-folder",
);
const allowWrites = dirs.locator(".addfolder-write input[type=checkbox]");
await expect(allowWrites).not.toBeChecked();
await dirs.getByPlaceholder(/Choose or paste a folder path/).fill("/tmp/ro-data");
await dirs.getByRole("button", { name: "Add", exact: true }).click();
const roRow = dirs.locator(".root-row").filter({ hasText: "/tmp/ro-data" });
await expect(roRow.getByRole("button", { name: "Read-only" })).toBeVisible();
// Add another, this time opting into writes → it lands read-write.
await dirs.getByRole("button", { name: "Give access to a folder" }).click();
await dirs.getByPlaceholder(/Choose or paste a folder path/).fill("/tmp/rw-data");
await dirs.locator(".addfolder-write input[type=checkbox]").check();
await dirs.getByRole("button", { name: "Add", exact: true }).click();
const rwRow = dirs.locator(".root-row").filter({ hasText: "/tmp/rw-data" });
await expect(rwRow.getByRole("button", { name: "Read-write" })).toBeVisible();
// Flip the read-only one to read-write via its access button (upsert re-add).
await roRow.getByRole("button", { name: "Read-only" }).click();
await expect(roRow.getByRole("button", { name: "Read-write" })).toBeVisible();
// Remove a non-primary folder — the primary can't be removed.
await rwRow.getByTitle("Remove").click();
await expect(dirs.locator(".root-row").filter({ hasText: "/tmp/rw-data" })).toHaveCount(0);
});

View File

@@ -0,0 +1,154 @@
// Seeded-transcript replay (the reopen path). Everything here renders from
// GET /v1/sessions/{id}/messages via itemsFromMessages — no live turns are driven —
// which is the one path the fake agent's echo scripting could never reach: replayed
// tool calls with results, privacy-filter counts, reasoning disclosures, persisted
// notices, and connector-sourced inbound messages.
import { expect } from "@playwright/test";
import { test, seedSessionMessages } from "./fixtures";
const TS = 1755600000; // fixed epoch — replay must not depend on "now"
const RICH_HISTORY = [
{ role: "user", content: "Audit the release branch", ts: TS },
{
role: "assistant",
content: "",
tool_calls: [
{ id: "t1", function: { name: "run_shell", arguments: JSON.stringify({ command: "git log --oneline -5" }) } },
{ id: "t2", function: { name: "read_file", arguments: JSON.stringify({ path: "CHANGELOG.md" }) } },
],
},
{ role: "tool", tool_call_id: "t1", content: "abc123 release: cut 0.1.7" },
{ role: "tool", tool_call_id: "t2", content: "## 0.1.7 — fixes", _display: { hidden_by_filters: 3 } },
{
role: "assistant",
content: "The branch is clean — **two checks** passed.",
reasoning: "Compared the log against the changelog; both entries line up.",
ts: TS + 40,
},
{ role: "notice", kind: "compacted", text: "Context compacted" },
{ role: "assistant", content: "Anything else before I file the summary?" },
];
test("a reopened session replays rich history: tools, filters, reasoning, notices", async ({
page,
}) => {
await seedSessionMessages(page, "pinned-cowork-1", RICH_HISTORY);
await page.goto("/");
await page.getByText("Draft the launch note").first().click();
// Plain items replay as they rendered live.
await expect(page.getByText("Audit the release branch")).toBeVisible();
await expect(page.locator(".md strong", { hasText: "two checks" })).toBeVisible();
await expect(page.getByText("Context compacted")).toBeVisible();
// The turn's tools fold into a collapsed step group; the filter count rides the summary.
const group = page.locator(".stepgroup").first();
await expect(group).toContainText("2 steps");
await expect(page.getByTestId("stepgroup-hidden")).toContainText("3 hidden");
// Expanding reveals the replayed rows with their results wired by tool_call_id.
await group.locator("summary").click();
await expect(page.getByTestId("turn-step")).toHaveCount(2);
await expect(page.getByTestId("tool-hidden-count")).toBeVisible();
// Reasoning persists as the collapsed disclosure, not live "Thinking…".
await expect(page.getByTestId("thinking-toggle")).toContainText("Thought process");
});
test("a connector-sourced message replays as its structured card", async ({ page }) => {
await seedSessionMessages(page, "pinned-cowork-1", [
{
role: "user",
content: "[slack] Priya: Ship it when the checks are green",
source: {
connector: "slack",
kind: "channel",
channel_id: "C0REL",
channel_name: "#release",
sender_id: "U1",
sender_name: "Priya",
ts: TS,
text: "Ship it when the checks are green",
},
},
{ role: "assistant", content: "Will do — watching the checks now." },
]);
await page.goto("/");
await page.getByText("Draft the launch note").first().click();
const card = page.locator(".connector-card[data-brand='slack']");
await expect(card).toBeVisible();
await expect(card).toContainText("Priya");
await expect(card).toContainText("Ship it when the checks are green");
// The framed model-facing content must NOT double-render as a plain bubble.
await expect(page.getByText("[slack] Priya:")).toHaveCount(0);
});
test("a replayed error notice at the tail offers Retry", async ({ page }) => {
await seedSessionMessages(page, "pinned-cowork-1", [
{ role: "user", content: "run the report", ts: TS },
{ role: "notice", kind: "error", text: "provider unavailable" },
]);
await page.goto("/");
await page.getByText("Draft the launch note").first().click();
await expect(page.getByText("Error: provider unavailable")).toBeVisible();
await expect(page.getByTestId("notice-retry")).toBeVisible();
});
test("a dead MCP server replays as one quiet line with Details and Open Connectors", async ({
page,
}) => {
// Owner ruling 2026-08-21: never a wall of stderr in the transcript — the summary
// names the server; the raw error hides behind Details; Open Connectors is the fix path.
await seedSessionMessages(page, "pinned-cowork-1", [
{ role: "user", content: "hi", ts: TS },
{ role: "assistant", content: "Hello!" },
{
role: "notice",
kind: "mcp_error",
server: "sales-db",
text: "MCP server “sales-db” failed to start: unhandled errors in a TaskGroup — aws configure export-credentials --profile aicreator exited 255",
},
]);
await page.goto("/");
await page.getByText("Draft the launch note").first().click();
const line = page.getByTestId("mcp-notice");
await expect(line).toContainText("sales-db");
await expect(line).toContainText("didnt start");
// The raw error stays hidden until asked for.
await expect(page.getByTestId("mcp-notice-detail")).toHaveCount(0);
await page.getByTestId("mcp-notice-details").click();
await expect(page.getByTestId("mcp-notice-detail")).toContainText("TaskGroup");
// Open Connectors jumps to the Integrations surface.
await page.getByTestId("mcp-notice-connectors").click();
await expect(page.getByText("Connectors", { exact: true }).first()).toBeVisible();
});
test("a LEGACY mcp_error notice (pre-server-field) also collapses to the quiet line", async ({
page,
}) => {
// Old sessions persisted the full text + a plain "see Settings ▸ Connectors" pointer;
// display-time parsing recovers the server name so old transcripts clean up too.
await seedSessionMessages(page, "pinned-cowork-1", [
{ role: "user", content: "hi", ts: TS },
{
role: "notice",
kind: "mcp_error",
text: "MCP server “sales-db” failed to start: unhandled errors in a TaskGroup <function f at 0x102ab40f0> — see Settings ▸ Connectors",
},
]);
await page.goto("/");
await page.getByText("Draft the launch note").first().click();
const line = page.getByTestId("mcp-notice");
await expect(line).toContainText("sales-db");
await page.getByTestId("mcp-notice-details").click();
const detail = page.getByTestId("mcp-notice-detail");
await expect(detail).toContainText("TaskGroup");
// The old plain-text pointer is dropped — the button replaces it.
await expect(detail).not.toContainText("see Settings");
});

View File

@@ -0,0 +1,88 @@
// Start-screen template tasks (§27): three concrete rows, no icon tiles, no "Set me up" list.
// Sub-lines are outcome-voiced; connection state lives in the dots + the trailing action.
// Gated row (source not live for this session) → "Configure " expands the rail's Access
// section (§32); ready row → click prefills the composer with the template stem.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
test("three rows, no Set-me-up; gated rows show Configure and expand the rail's Access section", async ({
page,
}) => {
await page.goto("/");
await expect(page.getByText("What should we produce?")).toBeVisible();
// Exactly the three template tasks; the old setup list is gone.
await expect(page.locator(".task-card")).toHaveCount(3);
await expect(page.getByText("Set me up (optional)")).toHaveCount(0);
await expect(page.getByText("Give me access to a folder")).toHaveCount(0);
// Fixture session state: slack + github live, hubspot not → the HubSpot row is gated,
// with the Configure affordance visible AT REST (no hover needed — it IS the row's action);
// the github+slack automation row has everything it needs.
const hs = page.getByTestId("intro-task-hubspot");
await expect(hs).toContainText("Configure ");
await expect(hs.locator(".task-card-act")).toHaveCSS("opacity", "1");
await expect(page.getByTestId("intro-task-github-slack")).toContainText("Start →");
// Sub-lines describe the task's outcome, never connection state.
await expect(hs).toContainText("Sources, stages, and who needs follow-up");
await expect(hs).not.toContainText(/connect/i);
// Configure → the rail's Access section expands (§32), not a bespoke setup surface.
await hs.click();
await expect(page.getByRole("region", { name: "Session access" })).toBeVisible();
// No composer prefill happened on the gated click.
await expect(page.getByPlaceholder(/Ask the coworker/)).toHaveValue("");
});
test("ready rows reveal Start → on hover and prefill the composer", async ({ page }) => {
// Make every source live for this session (registered after the fixture's routes → wins).
await page.route("**/v1/sessions/*/connections*", (route) =>
route.fulfill({
contentType: "application/json",
body: JSON.stringify({
connected: [
{ connector: "hubspot", enabled: true, detail: "" },
{ connector: "github", enabled: true, detail: "" },
{ connector: "slack", enabled: true, detail: "" },
],
recommended: [],
attention: 0,
}),
}),
);
await page.goto("/");
const hs = page.getByTestId("intro-task-hubspot");
await expect(hs).toContainText("Start →");
// The action is hover-revealed on ready rows (hidden at rest).
await expect(hs.locator(".task-card-act")).toHaveCSS("opacity", "0");
await hs.hover();
await expect(hs.locator(".task-card-act")).toHaveCSS("opacity", "1");
await hs.click();
await expect(page.getByPlaceholder(/Ask the coworker/)).toHaveValue(/HubSpot leads/);
// Both sources live → the automation row is ready too; its prefill is the recipe stem.
const gh = page.getByTestId("intro-task-github-slack");
await expect(gh).toContainText("Start →");
await gh.click();
await expect(page.getByPlaceholder(/Ask the coworker/)).toHaveValue(/weekly progress report/);
});
test("folder task opens the inline add-folder form; adding a folder prefills the composer", async ({
page,
}) => {
await page.goto("/");
// No shared folder yet (the fixture root is the primary scratch) → the row expands the form.
await page.getByTestId("intro-task-folder").click();
const path = page.getByPlaceholder("Choose or paste a folder path…");
await expect(path).toBeVisible();
await path.fill("/Users/me/Reports");
await page.getByRole("button", { name: "Add", exact: true }).click();
await expect(page.getByPlaceholder(/Ask the coworker/)).toHaveValue(
/Analyze the files in this folder/,
);
});

View File

@@ -0,0 +1,77 @@
// Session-screen cleanup (§22): the contextual top-left cluster ([sidebar][+][search], rendered
// ONLY while the sidebar is collapsed), the centered facts subtitle (persona · model — fixed
// facts replacing the locked-model pill and the topbar About-persona button), and the model
// picker's fresh-session-only placement.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
test("top-left cluster renders only while the sidebar is collapsed", async ({ page }) => {
await page.goto("/");
// Expanded sidebar owns those actions — no duplicate cluster.
await expect(page.locator(".sidebar")).toBeVisible();
await expect(page.getByTestId("topbar-cluster")).toHaveCount(0);
// Collapse → the cluster appears with all three actions; the floating reveal button does NOT
// double up on the session surface (the cluster's sidebar button replaces it).
await page.keyboard.press("Meta+b");
const cluster = page.getByTestId("topbar-cluster");
await expect(cluster).toBeVisible();
await expect(cluster.getByRole("button", { name: "Show sidebar" })).toBeVisible();
await expect(cluster.getByRole("button", { name: "New session" })).toBeVisible();
await expect(cluster.getByRole("button", { name: "Search" })).toBeVisible();
await expect(page.locator(".nav-reveal-btn")).toHaveCount(0);
// The cluster's search opens the command-palette overlay.
await cluster.getByRole("button", { name: "Search" }).click();
await expect(page.getByPlaceholder("Search chats")).toBeVisible();
await page.keyboard.press("Escape");
// The cluster's sidebar button docks the nav back — and the cluster leaves with it.
await cluster.getByRole("button", { name: "Show sidebar" }).click();
await expect(page.locator(".app")).not.toHaveClass(/nav-collapsed/);
await expect(page.getByTestId("topbar-cluster")).toHaveCount(0);
});
test("facts subtitle: absent on a fresh session, coworker + model after the first turn, inert", async ({
page,
}) => {
await page.goto("/");
// Fresh-ish (boot-resumed, no rendered history): no subtitle, no old About-persona button —
// and the model is a live PICKER in the composer (fresh sessions choose; nothing is locked yet).
await expect(page.getByTestId("session-subtitle")).toHaveCount(0);
await expect(page.getByRole("button", { name: "About this persona" })).toHaveCount(0);
await expect(page.locator(".dd").filter({ hasText: "Claude Opus 4.8" })).toBeVisible();
// First turn → the facts move up to the subtitle; the picker STAYS in the composer
// (§17 rev 2026-07-22: mid-session model switching shipped, so it remains actionable).
const box = page.getByPlaceholder(/Ask the coworker/);
await box.fill("hello");
await page.getByRole("button", { name: "Send" }).click();
await expect(page.getByText(/Echo: hello/)).toBeVisible();
// Coworker + model (UX-029 restored the coworker name — the picker shipped), and the
// subtitle is a plain fact line, not a button to the persona page.
const sub = page.getByTestId("session-subtitle");
await expect(sub).toHaveText("Coworker · Claude Opus 4.8");
await expect(page.locator(".dd").filter({ hasText: "Claude Opus 4.8" })).toBeVisible();
await sub.click();
await expect(page.getByRole("button", { name: "Back", exact: true })).toHaveCount(0);
});
test("composer is three controls (+ attach · Mode · send); folder and branch chips are gone", async ({
page,
}) => {
await page.goto("/");
await page.getByText("Draft the launch note").first().click();
await expect(page.getByRole("button", { name: "Attach" })).toBeVisible();
await expect(page.getByRole("button", { name: "Mode", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "Send" })).toBeVisible();
// The folder/roots popover trigger and the standalone Inbox control left the composer (§22).
await expect(page.getByTitle(/director(y|ies) the agent can use/)).toHaveCount(0);
await expect(page.getByTitle("Inbox routing")).toHaveCount(0);
await expect(page.locator(".wschip")).toHaveCount(0);
await expect(page.locator(".wsbranch")).toHaveCount(0);
});

View File

@@ -0,0 +1,168 @@
import { test, expect } from "./fixtures";
// Guards the Settings-as-page refactor (§13, IA per UX-021): the ⚙ menu opens a full-page
// surface with a left sub-nav — General · Models · Voice input — and each section renders.
// Files is a card inside General; Coworkers ships on (flag "0" hides it).
test("Settings opens as a full page and navigates sections", async ({ page }) => {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Settings", exact: true }).click();
// Full-page: left sub-nav + the General section (no modal backdrop).
await expect(page.getByRole("heading", { name: "General" })).toBeVisible();
await expect(page.locator(".modal-backdrop")).toHaveCount(0);
for (const label of ["General", "Models", "Voice input"]) {
await expect(page.getByRole("button", { name: label, exact: true })).toBeVisible();
}
// Folded tabs: Files is a General card now; Coworkers ships as its own tab (UX-029).
await expect(page.getByRole("button", { name: "Files", exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Coworkers", exact: true })).toBeVisible();
// The Files card lives inside General.
await expect(page.getByText("Each conversation gets its own folder")).toBeVisible();
await page.getByRole("button", { name: "Models", exact: true }).click();
await expect(page.getByTestId("set-provider-openai")).toBeVisible();
});
// The flag's "0" escape hatch hides the tab again (the default is on — UX-029).
test("Settings: Coworkers tab opens by default; flag \"0\" hides it", async ({ page }) => {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Settings", exact: true }).click();
await page.getByRole("button", { name: "Coworkers", exact: true }).click();
await expect(page.getByTestId("install-disclosure")).toBeVisible();
});
test("Settings: the flag escape hatch hides the Coworkers tab", async ({ page }) => {
await page.addInitScript(() => localStorage.setItem("ocw.flag.personas", "0"));
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Settings", exact: true }).click();
await expect(page.getByRole("heading", { name: "General" })).toBeVisible();
await expect(page.getByRole("button", { name: "Coworkers", exact: true })).toHaveCount(0);
});
// UX-021: Settings ▸ Models is the shared provider gallery (§39 components). Cards wear
// their own state (✓ Connected · used …); a vendor card opens the shared key form with the
// prefilled endpoint behind the disclosure; unconfigured providers preview their models.
test("Models: provider gallery states; vendor form previews models", async ({ page }) => {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Settings", exact: true }).click();
await page.getByRole("button", { name: "Models", exact: true }).click();
// Card states from the fixtures: openai configured+used, anthropic configured, zai not.
await expect(page.getByTestId("set-provider-openai")).toContainText("✓ Connected · used 2h ago");
await expect(page.getByTestId("set-provider-anthropic")).toContainText("✓ Connected");
await expect(page.getByTestId("set-provider-zai")).toContainText("Not set up");
await expect(page.getByTestId("set-provider-ollama")).toContainText("No key needed");
// The composer-picker card lists the curated models with provider tags.
const picker = page.getByTestId("composer-picker");
await expect(picker).toContainText("In the composer's picker");
// Vendor form: blurb renders; the prefilled endpoint hides behind the disclosure.
await page.getByTestId("set-provider-zai").click();
await expect(page.getByText(/Uses Z AI's OpenAI-compatible API/)).toBeVisible();
await page.getByTestId("set-endpoint-link").click();
await expect(page.getByTestId("set-field-base_url")).toHaveValue("https://api.z.ai/api/paas/v4");
// Unconfigured providers still preview their curated models (read-only, matrix labels).
const preview = page.getByTestId("model-preview");
await expect(preview).toContainText("Included models");
await expect(preview).toContainText("GLM-5.2 · Z AI");
// Back to the gallery via the crumb.
await page.getByTestId("set-back").click();
await expect(page.getByTestId("set-provider-openai")).toBeVisible();
});
test("Models: BytePlus and Volcengine Ark stay visually and operationally separate", async ({ page }) => {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Settings", exact: true }).click();
await page.getByRole("button", { name: "Models", exact: true }).click();
const byteplusCard = page.getByTestId("set-provider-ark");
const volcengineCard = page.getByTestId("set-provider-ark-agent-plan-cn");
await expect(byteplusCard).toContainText("BytePlus Ark");
await expect(volcengineCard).toContainText("Volcengine Ark Agent Plan");
const byteplusLogo = await byteplusCard.locator("img").getAttribute("src");
const volcengineLogo = await volcengineCard.locator("img").getAttribute("src");
expect(byteplusLogo).toBeTruthy();
expect(volcengineLogo).toBeTruthy();
expect(byteplusLogo).not.toBe(volcengineLogo);
await byteplusCard.click();
await page.getByTestId("set-endpoint-link").click();
await expect(page.getByTestId("set-field-base_url")).toHaveValue(
"https://ark.ap-southeast.bytepluses.com/api/v3",
);
let preview = page.getByTestId("model-preview");
await expect(preview).toContainText("Dola Seed Evolving · BytePlus Ark");
await expect(preview).toContainText("Dola Seed 2.1 Turbo · BytePlus Ark");
await expect(preview).not.toContainText("Doubao Seed");
await page.getByTestId("set-back").click();
await volcengineCard.click();
await page.getByTestId("set-endpoint-link").click();
await expect(page.getByTestId("set-field-base_url")).toHaveValue(
"https://ark.cn-beijing.volces.com/api/plan/v3",
);
preview = page.getByTestId("model-preview");
await expect(preview).toContainText("Doubao Seed Evolving · Volcengine Agent Plan");
await expect(preview).toContainText("Doubao Seed 2.1 Turbo · Volcengine Agent Plan");
await expect(preview).not.toContainText("Dola Seed");
});
// UX-021: a configured provider's form shows the in-field saved state and the Remove key…
// affordance; removing reverts the card to "Not set up".
test("Models: Remove key reverts a configured provider", async ({ page }) => {
await page.goto("/");
page.on("dialog", (d) => d.accept());
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Settings", exact: true }).click();
await page.getByRole("button", { name: "Models", exact: true }).click();
await page.getByTestId("set-provider-anthropic").click();
await expect(page.getByTestId("set-saved-pill")).toContainText("Tested & saved");
await page.getByTestId("set-remove-key").click();
// Back on the gallery, the card has forgotten its key.
await expect(page.getByTestId("set-provider-anthropic")).toContainText("Not set up");
});
// Token savings (owner ask 2026-07-17; now under Settings ▸ Context optimization,
// owner 2026-08-21): the card renders with the PDF fallback segmented control +
// attach thresholds, and edits POST through.
test("Settings: Token savings card edits PDF fallback and thresholds", async ({ page }) => {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Settings", exact: true }).click();
await page.getByRole("button", { name: "Context optimization", exact: true }).click();
const card = page.getByTestId("token-savings-card");
await expect(card).toBeVisible();
await expect(card.getByText("Token savings")).toBeVisible();
// Fallback mode: fixture says "text"; switching marks "Send page images" active.
const seg = page.getByTestId("pdf-fallback");
await expect(seg.getByRole("button", { name: "Extract text" })).toHaveClass(/active/);
const [req] = await Promise.all([
page.waitForRequest((r) => r.url().endsWith("/v1/settings/pdf") && r.method() === "POST"),
seg.getByRole("button", { name: "Send page images" }).click(),
]);
expect(req.postDataJSON()).toEqual({ pdf_fallback: "images" });
await expect(seg.getByRole("button", { name: "Send page images" })).toHaveClass(/active/);
// Thresholds: fixture starts at 2 pages / 10 MB; editing pages POSTs the clamped value.
await expect(card.getByTestId("pdf-max-pages")).toHaveValue("2");
await expect(card.getByTestId("pdf-max-mb")).toHaveValue("10");
const [req2] = await Promise.all([
page.waitForRequest((r) => r.url().endsWith("/v1/settings/pdf") && r.method() === "POST"),
card.getByTestId("pdf-max-pages").fill("30"),
]);
expect(req2.postDataJSON()).toEqual({ pdf_max_pages: 30 });
});

View File

@@ -0,0 +1,76 @@
import { test, expect } from "./fixtures";
// Sharing v1 (OPE-7): the picker's "Import coworker…" door, the zip-import consent flow
// (trust warning first, capabilities behind a chevron, replaces-note), and per-coworker
// export from Settings ▸ Coworkers.
test("picker's Import door lands on Settings ▸ Coworkers at the Add section", async ({ page }) => {
await page.goto("/");
await page.getByText("New session").first().click();
await page.getByTestId("coworker-chip").click();
await page.getByTestId("import-coworker").click();
// Settings ▸ Coworkers opened, with the installer disclosure auto-opened (UX-035:
// it's collapsed by default; the Import door pops it).
await expect(page.getByTestId("install-disclosure")).toBeVisible();
await expect(page.getByRole("combobox")).toBeVisible();
});
test("zip import: trust warning leads, tools collapse behind a chevron, replaces-note shows", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Settings", exact: true }).click();
await page.getByRole("button", { name: "Coworkers", exact: true }).click();
// Open the installer disclosure, pick the Bundle zip mode, feed a file through
// the hidden input.
await page.getByTestId("install-disclosure").click();
await page.getByRole("combobox").selectOption("zip");
await page.getByTestId("persona-zip-input").setInputFiles({
name: "team-sec.zip",
mimeType: "application/zip",
buffer: Buffer.from("fake-zip-bytes"),
});
const review = page.getByTestId("consent-review");
await expect(review).toBeVisible();
// The trust warning comes FIRST (owner design).
await expect(review.getByText(/Only enable coworkers from someone you trust/)).toBeVisible();
const card = page.getByTestId("consent-team-sec");
await expect(card.getByText("Team Security Coworker").first()).toBeVisible();
await expect(card.getByText(/Can read files, create & edit files and run shell commands/)).toBeVisible();
// Exact tools hidden until the chevron is clicked.
await expect(card.getByText("code_files · search · shell")).toHaveCount(0);
await card.getByTestId("consent-tools-toggle").click();
await expect(card.getByText("code_files · search · shell")).toBeVisible();
// Version + replaces + grew-capabilities re-consent note; recommended connector shown.
await expect(card.getByTestId("replaces-note")).toContainText("Replaces Team Security Coworker v1");
await expect(card.getByTestId("replaces-note")).toContainText("MORE capabilities");
await expect(card.getByText(/github.*(recommended).*open fix PRs/)).toBeVisible();
// Imported coworker landed disabled in the list above, pending consent —
// and the card itself carries the Enable action (no hunting back up the list).
const row = page.locator(".divide-y > div").filter({ hasText: "Team Security Coworker" });
await expect(row.getByRole("switch")).toHaveAttribute("aria-checked", "false");
await card.getByTestId("consent-enable-team-sec").click();
await expect(card.getByTestId("consent-enabled")).toContainText("it's in your coworker picker");
await expect(row.getByRole("switch")).toHaveAttribute("aria-checked", "true");
});
test("Export… zips an installed coworker's bundle to a chosen folder", async ({ page }) => {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Settings", exact: true }).click();
await page.getByRole("button", { name: "Coworkers", exact: true }).click();
// Export moved to the coworker detail page (UX-035); the native folder pick is
// server-mocked → /tmp/picked-folder.
await page.getByTestId("persona-configure-acme-notes").click();
await page.getByTestId("persona-export").click();
await expect(page.getByText("Exported to /tmp/picked-folder/acme-notes-coworker-v1.zip")).toBeVisible();
});

View File

@@ -0,0 +1,64 @@
// The sidebar bottom is exactly ONE row — the account anchor (UX-DECISIONS §26).
// Contract under test: no "Settings & more", no standalone Inbox/Connectors rows; the
// inbox chip is state-driven (accent + count when pending) and clicks STRAIGHT to Inbox
// while the rest of the row opens the account menu, which always lists Inbox + Connectors.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
test("the bottom is one account row — the old rows are gone", async ({ page }) => {
await page.goto("/");
await expect(page.getByTestId("account-row")).toBeVisible();
await expect(page.getByRole("button", { name: /Settings & more/i })).toHaveCount(0);
// No standalone sidebar Inbox row: outside the menu, "Inbox" exists only as the chip.
await expect(page.locator(".sidebar").getByRole("button", { name: "Inbox", exact: true })).toHaveCount(0);
});
test("pending items: the chip carries the count and goes straight to Inbox — no menu", async ({
page,
}) => {
await page.goto("/");
const chip = page.getByTestId("inbox-chip");
await expect(chip).toContainText(/\d/); // fixtures seed pending attention → accent count
await chip.click();
await expect(page.getByTestId("account-menu")).toHaveCount(0); // the chip never opens the menu
await expect(page.getByText("Approve: run_shell")).toBeVisible(); // Inbox opened directly
});
test("the account menu: Inbox + Connectors always listed; Settings carries the shortcut hint", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("account-row").click();
const menu = page.getByTestId("account-menu");
await expect(menu.getByRole("button", { name: "Inbox" })).toBeVisible();
await expect(menu.getByRole("button", { name: "Connectors", exact: true })).toBeVisible();
await expect(menu.getByRole("button", { name: /Settings/ })).toContainText("⌘");
// Automations left the menu (owner 2026-08-21) — the sidebar nav row carries it.
await expect(menu.getByRole("button", { name: "Automations", exact: true })).toHaveCount(0);
await expect(menu.getByRole("button", { name: "Activity", exact: true })).toBeVisible();
});
test("Activity in the menu is the audit log; Unrouted lives under Inbox ▸ Configure", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByTestId("account-menu").getByRole("button", { name: "Activity", exact: true }).click();
await expect(page.getByRole("heading", { name: "Activity" })).toBeVisible();
// §28: Messaging routing left the Connectors sub-nav entirely — and the MCP tab
// retired into the Connectors page itself (UX-034), so one sub-nav item remains.
await page.getByTestId("account-row").click();
await page.getByTestId("account-menu").getByRole("button", { name: "Connectors", exact: true }).click();
await expect(page.getByTestId("add-custom-server")).toBeVisible();
await expect(page.getByRole("button", { name: "MCP servers" })).toHaveCount(0);
await expect(page.getByRole("button", { name: /Messaging routing/ })).toHaveCount(0);
// The old fourth sub-nav tab is gone — exactly one page is named Activity now.
await expect(page.getByRole("button", { name: "Activity", exact: true })).toHaveCount(0);
// …and Unrouted rides the Inbox's Configure tab.
await page.getByTestId("account-row").click();
await page.getByTestId("account-menu").getByRole("button", { name: "Inbox" }).click();
await page.getByTestId("inbox-tab-configure").click();
await expect(page.getByTestId("unrouted-section")).toBeVisible();
});

View File

@@ -0,0 +1,72 @@
// UX-023: automations get sidebar presence — an "Automations" nav row under Search
// (aggregate unseen badge) and a "Scheduled" band with ONE entry per automation
// (name + cadence + unseen-runs badge). Opening an automation's detail marks it
// seen: the badge clears immediately via the AUTOMATIONS_CHANGED broadcast, and
// runs newer than the pre-open mark wear a "new" pill inside the detail.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
test("nav row + Scheduled band render with unseen badges; runs stay out of Recent", async ({
page,
}) => {
await page.goto("/");
// Nav row sits right under Search — no badge of its own (owner call: the
// Scheduled entry alone carries the count).
const nav = page.getByTestId("nav-automations");
await expect(nav).toBeVisible();
await expect(nav).toContainText("Automations");
await expect(nav).not.toContainText("2");
// Scheduled band: one entry PER AUTOMATION — never per run. The noisy task wears
// its badge; the quiet one shows none.
const band = page.getByTestId("scheduled-band");
await expect(band.getByTestId("scheduled-task-1")).toContainText("Daily AI News");
await expect(band.getByTestId("scheduled-task-1")).toContainText("2");
await expect(band.getByTestId("scheduled-task-2")).toContainText("Weekly CRM digest");
await expect(band.getByTestId("scheduled-task-2")).not.toContainText("2");
// Runs never appear as session rows (their sessions are __run__-prefixed and the
// server hides them) — the band's entries are the only automation presence.
await expect(page.getByTitle("__run__r1")).toHaveCount(0);
});
test("opening a Scheduled entry lands on the detail, marks seen, clears the badge", async ({
page,
}) => {
await page.goto("/");
await page.getByTestId("scheduled-task-1").click();
// The Automations surface opens ON that automation's detail…
await expect(page.getByRole("heading", { name: "Daily AI News" })).toBeVisible();
// …runs newer than the pre-open seen mark wear the "new" pill…
await expect(page.getByTestId("run-new").first()).toBeVisible();
// …and the entry's badge clears without waiting for any poll (mark-seen broadcast).
await expect(page.getByTestId("scheduled-task-1")).not.toContainText("2");
});
test("the nav row opens the Automations overview", async ({ page }) => {
await page.goto("/");
await page.getByTestId("nav-automations").click();
await expect(page.getByRole("heading", { name: "Automations" })).toBeVisible();
});
test("deleting an automation clears the band at once; nav re-entry lands on the list", async ({
page,
}) => {
await page.goto("/");
// Open the automation from the band, delete it from the detail.
await page.getByTestId("scheduled-task-2").click();
await expect(page.getByRole("heading", { name: "Weekly CRM digest" })).toBeVisible();
await page.getByRole("button", { name: /Delete/ }).click();
// The Scheduled band drops the entry immediately (broadcast, not the 15s poll)…
await expect(page.getByTestId("scheduled-task-2")).toHaveCount(0);
// …and after visiting a session, the nav row must land on the OVERVIEW — the
// remembered detail target for a deleted automation once left "Loading…" forever.
await page.getByTitle("Weekly plan 1").click();
await page.getByTestId("nav-automations").click();
await expect(page.getByRole("heading", { name: "Automations" })).toBeVisible();
await expect(page.getByText("Loading…")).toHaveCount(0);
});

View File

@@ -0,0 +1,15 @@
import { test, expect } from "./fixtures";
// Session rows are SINGLE-LINE (UX-DECISIONS §7, 2026-07-21): title only — the
// persona/workspace subtitle is gone (personas are launch-flagged off; when they return
// the persona surfaces on hover, not as a second line).
test("recent session rows render the title only — no persona subtitle", async ({ page }) => {
await page.goto("/");
const row = page
.locator(".sidebar .group")
.filter({ hasText: "Draft the launch note" })
.first();
await expect(row).toBeVisible();
const text = (await row.innerText()).trim();
expect(text).toBe("Draft the launch note");
});

View File

@@ -0,0 +1,104 @@
import { test, expect } from "./fixtures";
// Sidebar session lifecycle (owner testing pass, 2026-07-03): the peek cap (sessions_peek=5 →
// "Show more (2)" with 7 sessions), reversible archive with the Archived disclosure, and the
// two-step delete (Delete arms → "Delete?" confirms). All row actions sit behind the per-row
// ⋮ kebab (FB-011), so each flow goes hover → kebab → menu item.
test("session list caps at the peek count with Show more", async ({ page }) => {
await page.goto("/");
// Boot resumes a cowork session, so the Coworker accordion body is expanded. The body holds
// 9 sessions (7 weekly plans + the Slack-origin one §31 rev + the live-turn one) against
// sessions_peek=5.
await expect(page.getByTitle("Weekly plan 1")).toBeVisible();
await expect(page.getByTitle("Weekly plan 5")).toBeVisible();
await expect(page.getByTitle("Weekly plan 6")).toHaveCount(0);
await page.getByRole("button", { name: "Show more (4)" }).click();
await expect(page.getByTitle("Weekly plan 6")).toBeVisible();
await expect(page.getByTitle("Weekly plan 7")).toBeVisible();
});
test("archive via the row menu is reversible via the Archived disclosure", async ({ page }) => {
await page.goto("/");
const row = page.getByTitle("Weekly plan 2");
await expect(row).toBeVisible();
await row.hover();
await row.getByTestId("row-menu").click();
await row.getByTestId("row-menu-archive").click();
// Gone from the main list; parked under the Archived disclosure.
await expect(page.getByTitle("Weekly plan 2")).toHaveCount(0);
await page.getByRole("button", { name: /Archived \(1\)/ }).click();
const archivedRow = page.getByTitle("Weekly plan 2");
await expect(archivedRow).toBeVisible();
// Unarchive (same menu slot on an archived row) brings it straight back; the disclosure
// disappears with its last item.
await archivedRow.hover();
await archivedRow.getByTestId("row-menu").click();
await expect(archivedRow.getByTestId("row-menu-archive")).toHaveText("Unarchive");
await archivedRow.getByTestId("row-menu-archive").click();
await expect(page.getByRole("button", { name: /Archived/ })).toHaveCount(0);
await expect(page.getByTitle("Weekly plan 2")).toBeVisible();
});
test("mention-spawned sessions list in Recent with the platform icon — no From Slack band (§31 rev)", async ({
page,
}) => {
// Flat chronological layout — the launch default (personas off).
await page.route("**/v1/settings", (r) => r.fulfill({ json: { nav_layout: "flat" } }));
await page.goto("/");
await expect(page.getByTitle("Weekly plan 1")).toBeVisible();
// No collapsed band; the session sits directly in Recent, exactly once (its fixture
// timestamp sorts it past the peek cap, so expand first)…
await expect(page.getByTestId("from-slack-toggle")).toHaveCount(0);
await page.getByText(/Show \d+ more/).click();
const row = page.getByTitle("#general — check the deploy?");
await expect(row).toBeVisible();
await expect(page.getByTitle("#general — check the deploy?")).toHaveCount(1);
// …wearing the Slack logo (hover-hidden cluster, so assert attachment not visibility).
await expect(row.locator('[data-logo="slack"]')).toHaveCount(1);
});
test("pin via the row menu moves the session to the Pinned band and back", async ({ page }) => {
await page.goto("/");
const row = page.getByTitle("Weekly plan 4");
await expect(row).toBeVisible();
await row.hover();
await row.getByTestId("row-menu").click();
await expect(row.getByTestId("row-menu-pin")).toHaveText("Pin");
await row.getByTestId("row-menu-pin").click();
// Pinned rows live ONLY in the cross-persona Pinned band — no duplicate in the body.
const pinnedBand = page.getByText("Pinned", { exact: true }).locator("..");
await expect(pinnedBand.getByTitle("Weekly plan 4")).toBeVisible();
await expect(page.getByTitle("Weekly plan 4")).toHaveCount(1);
const pinnedRow = pinnedBand.getByTitle("Weekly plan 4");
await pinnedRow.hover();
await pinnedRow.getByTestId("row-menu").click();
await expect(pinnedRow.getByTestId("row-menu-pin")).toHaveText("Unpin");
await pinnedRow.getByTestId("row-menu-pin").click();
await expect(pinnedBand.getByTitle("Weekly plan 4")).toHaveCount(0);
await expect(page.getByTitle("Weekly plan 4")).toHaveCount(1);
});
test("delete is two-step: the menu's Delete arms, Delete? confirms", async ({ page }) => {
await page.goto("/");
const row = page.getByTitle("Weekly plan 3");
await expect(row).toBeVisible();
await row.hover();
await row.getByTestId("row-menu").click();
await row.getByTestId("row-menu-delete").click();
// First click only ARMS — the menu stays open showing the confirm affordance, the row remains.
await expect(row.getByTestId("row-menu-delete")).toHaveText("Delete?");
await expect(page.getByTitle("Weekly plan 3")).toHaveCount(1);
await row.getByTestId("row-menu-delete").click();
await expect(page.getByTitle("Weekly plan 3")).toHaveCount(0);
});

View File

@@ -0,0 +1,30 @@
import { test, expect } from "./fixtures";
// SKILLS-SPEC §9 journey 4 — the "/" force-run: popup pick inserts the inline `/name `
// prefix, the send carries the skill as its OWN WebSocket field (never as message text),
// and the transcript shows ONE truthful bubble with exactly what the user typed.
test("skills-forcerun: popup pick → inline /name → skill rides the frame → one bubble", async ({ page }) => {
await page.goto("/");
await page.getByText("Draft the launch note").first().click();
// "/" opens the popup; picking inserts the inline prefix (no chip) and keeps focus.
const box = page.getByPlaceholder(/Ask the coworker/);
await box.fill("/");
await expect(page.getByTestId("skill-popup")).toBeVisible();
await page.getByText("/weekly-report").click();
await expect(box).toHaveValue("/weekly-report ");
await box.type("cover last week");
await box.press("Enter");
// ONE user bubble, showing the literal line the user typed — never the model-facing
// "load this skill…" framing (§6: the _display contract).
await expect(page.getByText("/weekly-report cover last week")).toHaveCount(1);
await expect(page.getByText(/Use the skill/)).toHaveCount(0);
// The fake agent echoes what actually rode the wire: text WITHOUT the prefix, and the
// skill as its own field.
await expect(page.getByText(/\[skill=weekly-report\]/)).toBeVisible();
await expect(page.getByText(/Echo: cover last week/)).toBeVisible();
});

View File

@@ -0,0 +1,39 @@
import { test, expect } from "./fixtures";
// SKILLS-SPEC §9 journey 2 — liveness from the session's seat: the composer's "/" popup is
// the live "what can my worker use right now" view. A skill created in Settings is offered;
// a disabled one vanishes. Hermetic: the popup reads /v1/sessions/{id}/skills from fixtures.
test("skills-session: new skill offered in '/', disabled one absent", async ({ page }) => {
await page.goto("/");
await page.getByText("Draft the launch note").first().click();
// The seeded menu: both enabled skills offered on "/".
const box = page.getByPlaceholder(/Ask the coworker/);
await box.fill("/");
await expect(page.getByTestId("skill-popup")).toBeVisible();
await expect(page.getByText("/weekly-report")).toBeVisible();
await expect(page.getByText("/html-to-markdown")).toBeVisible();
await box.fill(""); // close the popup
// Settings round-trip: create one skill, disable another.
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Settings", exact: true }).click();
await page.getByRole("button", { name: "Skills", exact: true }).click();
await page.getByRole("button", { name: /Add skill/ }).click();
await page.getByText("Write it myself").click();
await page.getByLabel("Name").fill("fresh-skill");
await page.getByLabel("Instructions").fill("Do the fresh thing.");
await page.getByRole("button", { name: "Save skill" }).click();
await expect(page.getByRole("status")).toContainText("fresh-skill");
await page.getByLabel("weekly-report enabled").click();
await expect(page.getByRole("status")).toContainText("turned off everywhere");
// Back in the session: the popup reflects the new state — created offered, disabled gone.
await page.getByText("Draft the launch note").first().click();
await box.fill("/");
await expect(page.getByTestId("skill-popup")).toBeVisible();
await expect(page.getByText("/fresh-skill")).toBeVisible();
await expect(page.getByText("/weekly-report")).toHaveCount(0);
await expect(page.getByText("/html-to-markdown")).toBeVisible(); // untouched one persists
});

View File

@@ -0,0 +1,65 @@
import { test, expect } from "./fixtures";
// SKILLS-SPEC §9 journey 1 — Settings ▸ Skills as the management home: create through the
// Add-skill menu, edit in place, disable with the amber clean-slate banner, and the
// rich-skill folder chip. Hermetic: every /v1 call lands in fixtures.ts.
const openSkills = async (page: import("@playwright/test").Page) => {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Settings", exact: true }).click();
await page.getByRole("button", { name: "Skills", exact: true }).click();
};
test("skills-settings: create via the menu → name-first banner; edit persists", async ({ page }) => {
await openSkills(page);
// The seeded rows render; the rich one wears its folder chip; the list is the page
// (no standing add-surfaces).
await expect(page.getByText("weekly-report")).toBeVisible();
await expect(page.getByText("uploaded")).toBeVisible();
await expect(page.getByTitle("Show folder")).toContainText("2 files");
await expect(page.getByText("Start a conversation")).toHaveCount(0);
// Add skill ▾ → the three doors, then Write it myself.
await page.getByRole("button", { name: /Add skill/ }).click();
await expect(page.getByText("Import a file")).toBeVisible();
await expect(page.getByText("Create with OpenWorker")).toBeVisible();
await page.getByText("Write it myself").click();
await page.getByLabel("Name").fill("greet-warmly");
await page.getByLabel("Description").fill("Greets people warmly");
await page.getByLabel("Instructions").fill("Always greet warmly.");
await page.getByRole("button", { name: "Save skill" }).click();
// Name-first teal confirmation (§7) + the new row.
const status = page.getByRole("status");
await expect(status).toContainText("greet-warmly");
await expect(status).toContainText("can now use it in every conversation");
await expect(page.getByText("Greets people warmly")).toBeVisible();
// Edit: pencil prefills, name locked, save PATCHes through to the re-fetched list.
await page.getByTitle("Edit").first().click();
const name = page.getByLabel("Name");
await expect(name).toBeDisabled();
await page.getByLabel("Description").fill("Monday status report, sharper");
await page.getByRole("button", { name: "Save skill" }).click();
await expect(page.getByText("Monday status report, sharper")).toBeVisible();
});
test("skills-settings: disable → amber everywhere/clean-slate banner; delete is two-step", async ({ page }) => {
await openSkills(page);
await page.getByLabel("weekly-report enabled").click();
const status = page.getByRole("status");
await expect(status).toContainText("weekly-report");
await expect(status).toContainText("turned off everywhere");
await expect(status).toContainText("start a new one for a completely clean slate");
// Two-step delete: arm, confirm, row gone, banner names the skill.
await page.getByLabel("Delete html-to-markdown").click();
await expect(page.getByText("html-to-markdown")).toBeVisible(); // armed ≠ deleted
await page.getByText("Confirm delete").click();
await expect(page.getByText("html-to-markdown")).toHaveCount(1); // only the banner remains
await expect(page.getByRole("status")).toContainText("removed");
});

View File

@@ -0,0 +1,37 @@
import { test, expect } from "./fixtures";
// SKILLS-SPEC §9 journey 3 — import with the mandatory review gate: the preview installs
// NOTHING; confirm installs and the row wears the `uploaded` provenance badge. Hermetic:
// stage/confirm round-trip through fixtures.ts state.
test("skills-upload: preview installs nothing → confirm → uploaded badge", async ({ page }) => {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Settings", exact: true }).click();
await page.getByRole("button", { name: "Skills", exact: true }).click();
// Add skill ▾ → Import a file → straight to the (hidden) picker.
await page.getByRole("button", { name: /Add skill/ }).click();
await page.getByText("Import a file").click();
await page.getByLabel("Upload a skill archive").setInputFiles({
name: "greet.zip",
mimeType: "application/zip",
buffer: Buffer.from("PKfake"),
});
// The mandatory review screen: everything parsed, nothing installed yet.
await expect(page.getByText("Review before installing")).toBeVisible();
await expect(page.getByText("says hello")).toBeVisible();
await expect(page.getByText("Say hello warmly.")).toBeVisible();
await expect(page.getByText(/notes\.txt/)).toBeVisible();
await expect(page.getByText("greet", { exact: true })).toHaveCount(1); // preview only, no row
await page.getByRole("button", { name: "Install skill" }).click();
// Installed: teal name-first banner, a real row with the provenance badge + folder chip.
const status = page.getByRole("status");
await expect(status).toContainText("greet");
await expect(status).toContainText("can now use it in every conversation");
await expect(page.getByText("greet", { exact: true })).toHaveCount(2); // banner + the new row
await expect(page.getByText("uploaded")).toHaveCount(2); // html-to-markdown + greet
});

View File

@@ -0,0 +1,82 @@
// The Slack rosters: pick people from the workspace directory (instead of the
// park→approve-only flow) and resolve channel NAMES to ids in the channel picker.
// Both are reads on scopes every install already granted — no consent bump.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
async function openSlackPage(page) {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Connectors", exact: true }).click();
await page.getByTestId("connector-slack").click();
}
test("people picker: type a name, pick it, chip lands with the display name", async ({
page,
}) => {
await openSlackPage(page);
// T1DL starts empty → the hint row carries the picker.
await page.getByTestId("add-person-T1DL").click();
const picker = page.getByTestId("person-picker");
await picker.getByPlaceholder("Type a name…").fill("ro");
await page.getByTestId("pick-person-U8ROHIT").click();
// The chip shows the display name immediately (no first message needed).
const group = page.getByTestId("slack-workspace-T1DL");
await expect(group).toContainText("Rohit Prasad");
await expect(page.getByTestId("person-picker")).toHaveCount(0);
// The other workspace is untouched.
await expect(page.getByTestId("slack-workspace-T2AC")).toContainText("No one allowed yet");
});
test("people picker: guests are tagged, allowed users drop out of the list", async ({
page,
}) => {
await openSlackPage(page);
await page.getByTestId("add-person-T1DL").click();
const picker = page.getByTestId("person-picker");
await expect(picker.getByTestId("pick-person-U7CAL")).toContainText("guest");
await picker.getByPlaceholder("Type a name…").fill("maya");
await picker.getByTestId("pick-person-U9MAYA").click();
await expect(page.getByTestId("slack-workspace-T1DL")).toContainText("Maya Chen");
// Reopen: Maya is allowed now, so she's no longer offered.
await page.getByTestId("add-person-T1DL").click();
await expect(page.getByTestId("person-picker")).toBeVisible();
await expect(page.getByTestId("pick-person-U9MAYA")).toHaveCount(0);
await expect(page.getByTestId("pick-person-U8ROHIT")).toBeVisible();
});
test("channel typeahead: a NAME resolves to the workspace's id-address", async ({
page,
}) => {
await page.goto("/");
await page.getByText("Draft the launch note").first().click();
await page.getByTestId("access-toggle").click();
await page.getByRole("button", { name: /Channels · 0/ }).click();
const input = page.getByPlaceholder("slack:C0123 or channel link");
await input.fill("launch");
// Two workspaces are connected → the hit is labeled with its workspace.
const hit = page.getByTestId("roster-channel-slack:T1DL/C9LAUNCH");
await expect(hit).toContainText("#launch-team");
await expect(hit).toContainText("deeplearning.ai");
await hit.click();
// Display = the NAME after a pick (owner catch 2026-07-11: raw ids leaked into the box);
// the raw address survives underneath — the tooltip carries it and Add subscribes by id.
await expect(input).toHaveValue("#launch-team");
await expect(input).toHaveAttribute("title", "slack:T1DL/C9LAUNCH");
await page.getByRole("button", { name: "Add", exact: true }).click();
await expect(page.getByText(/Subscribed channels · 1/)).toBeVisible();
});
test("channel typeahead: private and not-a-member states are honest", async ({ page }) => {
await page.goto("/");
await page.getByText("Draft the launch note").first().click();
await page.getByTestId("access-toggle").click();
await page.getByRole("button", { name: /Channels · 0/ }).click();
await page.getByPlaceholder("slack:C0123 or channel link").fill("l");
await expect(page.getByTestId("roster-channel-slack:T1DL/C8LEADS")).toContainText("🔒");
await expect(page.getByTestId("roster-channel-slack:T1DL/C7LOBBY")).toContainText(
"invite @ocw",
);
});

View File

@@ -0,0 +1,82 @@
// Slack connection health (M3.6 Step 2, UX-DECISIONS §21): the list chip and the
// detail status line surface three honest layers — cloud sign-in, the desktop↔relay
// socket, per-workspace bot tokens — and never a synthetic "Slack is down" claim.
// The fixture's /v1/connectors/slack/status reads live+signed-out by default; each
// state here is forced with a later page.route override (later routes match first).
import { expect } from "@playwright/test";
import { test } from "./fixtures";
async function openConnectors(page) {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Connectors", exact: true }).click();
}
function statusPayload(overrides: any = {}) {
return {
ok: true,
mode: "relay",
relay: { state: "live", reconnects: 0, last_event_at: 1751970000, last_error: "" },
signed_in: true,
teams: { T1DL: { token_ok: true }, T2AC: { token_ok: true } },
...overrides,
};
}
function forceStatus(page, overrides: any) {
return page.route("**/v1/connectors/slack/status", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(statusPayload(overrides)),
}),
);
}
test("signed out: chip and status line say Sign-in needed", async ({ page }) => {
await openConnectors(page);
await expect(page.getByTestId("connector-slack")).toContainText("Sign-in needed");
await page.getByTestId("connector-slack").click();
await expect(page.getByTestId("slack-mode-badge")).toContainText(
"Sign-in needed — relaying is paused",
);
});
test("signed in + live socket: Live everywhere", async ({ page }) => {
await forceStatus(page, {});
await openConnectors(page);
await expect(page.getByTestId("connector-slack")).toContainText("Ready");
await page.getByTestId("connector-slack").click();
await expect(page.getByTestId("slack-mode-badge")).toContainText("Live · managed relay");
});
test("relay socket reconnecting: warn chip + status line", async ({ page }) => {
await forceStatus(page, {
relay: { state: "reconnecting", reconnects: 3, last_event_at: null, last_error: "boom" },
});
await openConnectors(page);
await expect(page.getByTestId("connector-slack")).toContainText("Reconnecting");
await page.getByTestId("connector-slack").click();
await expect(page.getByTestId("slack-mode-badge")).toContainText("Reconnecting to the relay");
});
test("relay unreachable: Offline, not a Slack-outage claim", async ({ page }) => {
await forceStatus(page, {
relay: { state: "offline", reconnects: 0, last_event_at: null, last_error: "unreachable" },
});
await openConnectors(page);
await expect(page.getByTestId("connector-slack")).toContainText("Offline");
await page.getByTestId("connector-slack").click();
await expect(page.getByTestId("slack-mode-badge")).toContainText("can't reach the relay");
});
test("one dead bot token: ⚠ chip + a warning on THAT workspace only", async ({ page }) => {
await forceStatus(page, {
teams: { T1DL: { token_ok: true }, T2AC: { token_ok: false } },
});
await openConnectors(page);
await expect(page.getByTestId("connector-slack")).toContainText("Token");
await page.getByTestId("connector-slack").click();
await expect(page.getByTestId("token-warn-T2AC")).toContainText("Token revoked");
await expect(page.getByTestId("token-warn-T1DL")).toHaveCount(0);
});

View File

@@ -0,0 +1,66 @@
// UX-027: the Slack post-connect orientation card — installer pre-added to the
// allow-list ("you" chip), status line, 3-tab animated how-it-works carousel
// (no "Listen to a channel" — deferred by owner call), collapse persisted locally.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
async function openSlackPage(page) {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Connectors", exact: true }).click();
await page.getByTestId("connector-slack").click();
}
test("post-connect card: personalized status line + the installer's 'you' chip", async ({
page,
}) => {
await openSlackPage(page);
const card = page.getByTestId("slack-howitworks");
await expect(card).toContainText("Getting started with Slack & OpenWorker");
await expect(card).toContainText("deeplearning.ai connected");
await expect(card).toContainText("you're on the People list");
// The pre-added installer renders as a named chip marked "you" in ITS workspace.
const chip = page.getByTestId("slack-workspace-T1DL").getByTestId("people-chip-you");
await expect(chip).toContainText("Rohit Prasad");
await expect(chip).toContainText("· you");
});
test("carousel has exactly the 3 shipped scenes and tabs switch the caption", async ({
page,
}) => {
await openSlackPage(page);
const card = page.getByTestId("slack-howitworks");
await expect(card.getByTestId("hiw-tab-0")).toContainText("Mention → session");
await expect(card.getByTestId("hiw-tab-1")).toContainText("Threads stay connected");
await expect(card.getByTestId("hiw-tab-2")).toContainText("Allow teammates");
await expect(card).not.toContainText("Listen to a channel"); // deferred (rev 4)
await expect(card.getByTestId("hiw-caption")).toContainText("a session opens here");
// rev 7: the post-it layer restates the concept in place
await expect(card.getByTestId("hiw-scene")).toContainText("a @mention starts a NEW session");
await card.getByTestId("hiw-tab-1").click();
await expect(card.getByTestId("hiw-caption")).toContainText("same session");
await expect(card.getByTestId("hiw-scene")).toContainText("2 replies");
await expect(card.getByTestId("hiw-scene")).toContainText("continues the SAME conversation");
await card.getByTestId("hiw-tab-2").click();
await expect(card.getByTestId("hiw-caption")).toContainText("waits for your OK");
await expect(card.getByTestId("hiw-scene")).toContainText("Allow & deliver");
});
test("collapse hides the carousel, keeps the status line, and survives a reload", async ({
page,
}) => {
await openSlackPage(page);
const card = page.getByTestId("slack-howitworks");
await expect(card.getByTestId("hiw-tab-0")).toBeVisible();
await card.getByTestId("hiw-collapse").click();
await expect(card.getByTestId("hiw-tab-0")).toHaveCount(0);
await expect(card).toContainText("you're on the People list"); // status line stays
await openSlackPage(page); // full re-navigation — the seen-state is local
await expect(page.getByTestId("slack-howitworks")).toBeVisible();
await expect(page.getByTestId("hiw-tab-0")).toHaveCount(0);
await page.getByTestId("hiw-collapse").click(); // reopen works
await expect(page.getByTestId("hiw-tab-0")).toBeVisible();
});

View File

@@ -0,0 +1,105 @@
// The Slack detail page (M3.6, UX-DECISIONS §21): one group per workspace with
// People / Waiting / Listening rows, add-workspace via the header-button MODAL
// (One click | Manual), per-workspace disconnect (stop-relaying-only), and the
// manual Socket-Mode card so neither connect path regresses.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
async function openSlackPage(page) {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Connectors", exact: true }).click();
await page.getByTestId("connector-slack").click();
}
test("lists every connected workspace as its own group", async ({ page }) => {
await openSlackPage(page);
await expect(page.getByTestId("slack-workspace-T1DL")).toContainText("deeplearning.ai");
await expect(page.getByTestId("slack-workspace-T2AC")).toContainText("acme-partners");
// The workspace domain is the visible differentiator (ids demote to hover).
await expect(page.getByTestId("slack-workspace-T1DL")).toContainText("· dlaiteam");
await expect(page.getByTestId("slack-workspace-T2AC")).toContainText("· acmehq");
// the workspace with people/parked shows the People row; the quiet one shows the hint
await expect(page.getByTestId("slack-workspace-T1DL")).toContainText("People");
await expect(page.getByTestId("slack-workspace-T2AC")).toContainText("No one allowed yet");
});
test("Add workspace opens the modal; signed out shows the sign-in hint, signed in installs", async ({
page,
}) => {
await openSlackPage(page);
await page.getByTestId("add-workspace-btn").click();
const modal = page.getByTestId("add-connection-modal");
await expect(modal).toContainText("Sign in to OpenWorker Cloud"); // signed out
// Manual pane is right there too — both modes, one entry point
await modal.getByTestId("modal-pane-manual").click();
await expect(modal.getByPlaceholder("Bot token · xoxb-…")).toBeVisible();
await page.keyboard.press("Escape");
// sign in from the list's cloud strip, then install one-click
await page.getByTestId("connectors-breadcrumb").click();
await page.getByTestId("account-row").click();
await page.getByTestId("account-sign-in").click();
await expect(page.getByTestId("account-row")).toContainText("Rohit", { timeout: 10_000 });
await page.getByTestId("connector-slack").click();
await page.getByTestId("add-workspace-btn").click();
await page.getByTestId("modal-add-to-slack").click();
// the mock completes the browser install instantly; the page's poll shows it
await expect(page.getByTestId("slack-workspace-T3NEW")).toContainText("new-workspace", {
timeout: 10_000,
});
await expect(page.getByTestId("slack-workspace-T1DL")).toBeVisible(); // existing ones stay
});
test("disconnect removes one workspace and keeps the rest relaying", async ({ page }) => {
await openSlackPage(page);
await page.getByTestId("disconnect-workspace-T2AC").click();
await expect(page.getByTestId("slack-workspace-T2AC")).toHaveCount(0);
await expect(page.getByTestId("slack-workspace-T1DL")).toBeVisible();
});
test("manual Socket Mode: one card with the flat allow-list (no regression)", async ({
page,
}) => {
let owners: string[] = [];
// Override the connectors payload AFTER mockApi so this test sees a manual-mode Slack
// (routes registered later match first).
await page.route("**/v1/connectors", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
connectors: [
{
name: "slack", title: "Slack", icon: "#", blurb: "Two-way Slack messaging.",
auth: "bot_token", two_way: true, available: true, brand_color: "#611f69",
logo: "slack", fields: [], instructions: [], connected: true, account: "acme",
enabled: true, allowed_users: ["U0OK"], allowed_user_names: { U0OK: "Rohit" },
approval_owner_ids: [...owners],
approval_owner_names: Object.fromEntries(owners.map((u) => [u, u === "U9MAYA" ? "Maya Chen" : u])),
tools: [], managed: true, managed_profile: false, mode: "", workspaces: [],
unauthorized: [],
},
],
}),
}),
);
await page.route("**/v1/connectors/slack/approval-owners/add", async (route) => {
const body = route.request().postDataJSON();
owners = [...new Set([...owners, body.user_id])];
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ ok: true, approval_owner_ids: owners }),
});
});
await openSlackPage(page);
await expect(page.getByTestId("slack-mode-badge")).toContainText("Socket Mode");
const card = page.getByTestId("slack-manual-card");
await expect(card).toContainText("acme");
await expect(card).toContainText("Rohit"); // flat allow-list chip, named
await expect(card).toContainText("Choose at least one owner");
await page.getByTestId("add-approval-owner").click();
await page.getByTestId("pick-person-U9MAYA").click();
await expect(page.getByTestId("approval-owner-U9MAYA")).toContainText("Maya Chen");
});

View File

@@ -0,0 +1,10 @@
import { test, expect } from "./fixtures";
test("app loads with the persona nav and composer", async ({ page }) => {
await page.goto("/");
await expect(page.getByText("OpenWorker").first()).toBeVisible();
// New session + Search are the fixed top nav.
await expect(page.getByRole("button", { name: /New session/i })).toBeVisible();
// The persona groups render from /v1/personas.
await expect(page.getByText("Ops", { exact: true })).toBeVisible();
});

View File

@@ -0,0 +1,100 @@
import { test, expect } from "./fixtures";
// Guards the per-session Slack channels drill-down (§14, hosted in the rail's Access section
// since §32): the "Channels" affordance is gated to two-way connectors, opens an inline child
// view, and add/remove round-trip through the subscribe APIs.
test("Slack channels drill-down: gating, add (auto-prefixed), remove", async ({ page }) => {
await page.goto("/");
// Open the pinned cowork session, then expand the rail's Access section.
await page.getByText("Draft the launch note").first().click();
await page.getByTestId("access-toggle").click();
const body = page.getByRole("region", { name: "Session access" });
await expect(body.getByText("Slack", { exact: true })).toBeVisible();
// Gating: only the two-way connector (Slack) gets a Channels affordance — not Browser.
await expect(page.getByRole("button", { name: /Channels ·/ })).toHaveCount(1);
await expect(page.getByRole("button", { name: /Channels · 0/ })).toBeVisible();
// Drill in.
await page.getByRole("button", { name: /Channels · 0/ }).click();
await expect(page.getByText("Slack channels")).toBeVisible();
await expect(page.getByText(/Not listening to any Slack channel yet/)).toBeVisible();
// Add a bare channel id — the panel scopes it to the connector (→ "slack:C0123").
await page.getByPlaceholder("slack:C0123 or channel link").fill("C0123");
await page.getByRole("button", { name: "Add", exact: true }).click();
await expect(page.getByText("slack:C0123", { exact: true })).toBeVisible();
await expect(page.getByText(/Subscribed channels · 1/)).toBeVisible();
// Remove it → back to the empty state.
await page.getByTitle("Stop listening").click();
await expect(page.getByText(/Not listening to any Slack channel yet/)).toBeVisible();
// Back returns to the Sources list.
await page.getByRole("button", { name: "Back to sources" }).click();
await expect(body.getByText("Slack", { exact: true })).toBeVisible();
});
// The recent-channels dropdown is a hand-rolled popover (NOT a <datalist> — WKWebView renders
// none), fed by /v1/channels/recent: focus opens it, typing filters, picking fills the input.
test("recent channels popover: opens on focus, filters, picks", async ({ page }) => {
await page.goto("/");
await page.getByText("Draft the launch note").first().click();
await page.getByTestId("access-toggle").click();
await page.getByRole("button", { name: /Channels · 0/ }).click();
const input = page.getByPlaceholder("slack:C0123 or channel link");
await input.click();
const pop = page.getByTestId("channel-suggestions");
// Named channels show "#name" with the address as a sub-label; unnamed fall back to the address.
await expect(pop.getByText("#ocw-test")).toBeVisible();
await expect(pop.getByText("slack:C0AAA111")).toBeVisible();
await expect(pop.getByText("bob: deploy failed")).toBeVisible();
// Typing part of the channel NAME filters too…
await input.fill("ocw");
await expect(pop.getByText("#ocw-test")).toBeVisible();
await expect(pop.getByText("slack:C0BBB222")).toHaveCount(0);
await input.fill("");
// Typing filters (matches address or message text)…
await input.fill("deploy");
await expect(pop.getByText("slack:C0AAA111")).toHaveCount(0);
await expect(pop.getByText("slack:C0BBB222")).toBeVisible();
// …and picking fills the input and closes the popover.
await pop.getByText("slack:C0BBB222").click();
await expect(input).toHaveValue("slack:C0BBB222");
await expect(page.getByTestId("channel-suggestions")).toHaveCount(0);
await page.getByRole("button", { name: "Add", exact: true }).click();
await expect(page.getByText(/Subscribed channels · 1/)).toBeVisible();
});
// Address-form fixes: a pasted Copy-link URL resolves to the id; a bare #name is rejected
// with the paste-the-ID hint instead of storing a dead subscription.
test("channel add: link URLs resolve, bare #names are rejected with a hint", async ({
page,
}) => {
await page.goto("/");
await page.getByText("Draft the launch note").first().click();
await page.getByTestId("access-toggle").click();
await page.getByRole("button", { name: /Channels · 0/ }).click();
const input = page.getByPlaceholder("slack:C0123 or channel link");
await input.fill("#general");
await page.getByRole("button", { name: "Add", exact: true }).click();
await expect(page.getByTestId("channel-add-error")).toContainText(
"paste the channel ID",
);
await expect(page.getByText(/Subscribed channels · 1/)).toHaveCount(0);
await input.fill("https://acme.slack.com/archives/C0123ABC");
// Typing again clears the rejection.
await expect(page.getByTestId("channel-add-error")).toHaveCount(0);
await page.getByRole("button", { name: "Add", exact: true }).click();
await expect(page.getByText("slack:C0123ABC")).toBeVisible();
await expect(page.getByText(/Subscribed channels · 1/)).toBeVisible();
});

View File

@@ -0,0 +1,94 @@
import { test, expect } from "./fixtures";
// Standing scoped approvals (UX-DECISIONS §25): the creation consent card renders the agent's
// proposed permission set (reads = disclosure, writes = grants); a recurring run's approval card
// offers the task-persistent "Allow every time" (in-app, run context only); and the automation's
// detail page lists granted rules with per-rule Revoke.
async function openTaskDetail(page: import("@playwright/test").Page) {
await page.goto("/");
// Via the nav row — the account-menu Automations entry was removed (UX-035 chrome cleanup).
await page.getByTestId("nav-automations").click();
await page.getByText("Daily AI News").first().click();
await expect(page.getByRole("button", { name: /Run now/ })).toBeVisible();
}
test("creation consent card renders writes as grants and reads as disclosure", async ({ page }) => {
await page.goto("/");
const box = page.getByPlaceholder(/Ask the coworker/);
await expect(box).toBeVisible();
await box.fill("please create an automation for the weekly digest");
await page.getByRole("button", { name: "Send" }).click();
// The approve-at-creation card carries the proposal instead of dumping raw JSON args.
const grants = page.getByTestId("approval-grants");
await expect(grants).toBeVisible();
await expect(grants).toContainText("slack:T1/C1");
await expect(grants).toContainText("always allowed once you approve");
await expect(grants).toContainText("rohit/agent-platform");
await expect(grants).toContainText("read-only");
// Creation is minting surface #1 — there is no "Allow every time" here.
await expect(page.getByRole("button", { name: "Allow every time" })).toHaveCount(0);
await page.getByRole("button", { name: "Allow once" }).last().click();
await expect(page.getByText("Done via create_scheduled_task [decision=once]")).toBeVisible();
});
test("a run session's approval card offers Allow every time and sends always_task", async ({
page,
}) => {
await openTaskDetail(page);
await page.getByRole("button", { name: /Run now/ }).click();
await expect(page.getByTestId("run-banner")).toBeVisible();
// The manual run auto-sends the task prompt; wait for that turn to finish (the composer
// re-arms) before driving the approval flow.
await expect(page.getByText(/Echo: .*Fetch the latest AI news/)).toBeVisible();
// An eligible gated write inside the run (the event carries the pinnable target).
const box = page.getByPlaceholder(/Ask the coworker/);
await box.fill("post the digest");
await page.getByRole("button", { name: "Send" }).click();
const allowEvery = page.getByRole("button", { name: "Allow every time" });
await expect(allowEvery).toBeVisible();
// The task-persistent grant replaces the session-scoped Always-allow in run context.
await expect(page.getByRole("button", { name: "Allow for this session", exact: true })).toHaveCount(0);
await allowEvery.click();
// The decision that rode the socket is the task-persistent one.
await expect(page.getByText("Done via send_message [decision=always_task]")).toBeVisible();
});
test("a plain session never offers Allow every time, even for an eligible call", async ({
page,
}) => {
await page.goto("/");
const box = page.getByPlaceholder(/Ask the coworker/);
await expect(box).toBeVisible();
await box.fill("post the digest");
await page.getByRole("button", { name: "Send" }).click();
// Same tool, same target — but without a run context the standing grant isn't offered;
// the session-scoped Always-allow remains.
await expect(page.getByRole("button", { name: "Allow once" }).last()).toBeVisible();
await expect(page.getByRole("button", { name: "Allow every time" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Allow for this session", exact: true }).last()).toBeVisible();
});
test("task detail lists standing rules under 'Allowed without asking'; Revoke removes one", async ({
page,
}) => {
await openTaskDetail(page);
const grants = page.getByTestId("task-grants");
await expect(page.getByText("Allowed without asking")).toBeVisible();
await expect(grants).toContainText("send_message");
await expect(grants).toContainText("slack:T1/C1");
await grants.getByRole("button", { name: "Revoke" }).click();
// The last rule is gone → the whole section disappears (nothing is allowed anymore).
await expect(page.getByTestId("task-grants")).toHaveCount(0);
await expect(page.getByText("Allowed without asking")).toHaveCount(0);
});

View File

@@ -0,0 +1,212 @@
// Agent teams (OPE-97): the staffing gate + the drawer's Team panel (seventeenth
// pass). The fake lead proposes a roster on "staff the team" and suspends; approval
// "pre-spawns" workers (the fixture mirrors create_team by adding worker sessions),
// which surface in the right drawer's Team section — the sidebar keeps ONE entry
// per team (the lead), with no expansion.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
async function proposeTeam(page: import("@playwright/test").Page) {
await page.goto("/");
await page.getByPlaceholder(/Ask the coworker/).fill("staff the team");
await page.getByRole("button", { name: "Send" }).click();
await expect(page.getByTestId("teamreq-card")).toBeVisible();
}
test("the decomposition gate shows items with criteria; approval lands them on the board", async ({
page,
}) => {
await page.goto("/");
await page.getByPlaceholder(/Ask the coworker/).fill("propose the split");
await page.getByRole("button", { name: "Send" }).click();
const card = page.getByTestId("itemsreq-card");
await expect(card).toBeVisible();
await expect(card).toContainText("Proposed work items — 4");
await expect(card).toContainText("Done when:");
// 3 visible + expander with the true remainder
await expect(card.getByText("Verification pass")).toHaveCount(0);
await card.getByRole("button", { name: /1 more item/ }).click();
await expect(card.getByText("Verification pass")).toBeVisible();
// essay-length criteria clamp behind a per-item expander (owner-hit 2026-08-16)
const acToggle = page.getByTestId("itemsreq-ac-toggle-0");
await expect(acToggle).toHaveText("Show full criteria");
await acToggle.click();
await expect(acToggle).toHaveText("Show less");
// the short-criteria items get no toggle
await expect(page.getByTestId("itemsreq-ac-toggle-1")).toHaveCount(0);
await page.getByTestId("itemsreq-approve").click();
await expect(page.getByText(/Items created on the board/)).toBeVisible();
// Sections start collapsed (a count chip is the maximum signal) — but the lead's
// one-time [Board · N items](board:) chip expands the drawer's Board section.
await expect(page.getByTestId("board-rail")).toHaveCount(0);
await page.getByTestId("board-chip").click();
await expect(page.getByTestId("board-rail")).toBeVisible();
});
test("typing while a gate is pending sends the reply as feedback to the lead", async ({
page,
}) => {
await proposeTeam(page);
// the composer re-opens for a typed answer instead of hard-blocking on "running"
const box = page.getByPlaceholder(/Reply to adjust the proposal/);
await box.fill("use openai:gpt-5.6-sol for all the workers");
await page.getByRole("button", { name: "Send" }).click();
// the reply lands as a user message AND resolves the gate as decline-with-feedback
await expect(
page.getByText("use openai:gpt-5.6-sol for all the workers"),
).toBeVisible();
await expect(page.getByText(/tell me how to change the roster/)).toBeVisible();
await expect(page.getByTestId("teamreq-card")).toHaveCount(0);
});
test("a board wake renders collapsed; expanding reveals rows, hand-offs stay one more click away", async ({
page,
}) => {
await page.goto("/");
await page.getByPlaceholder(/Ask the coworker/).fill("board wake");
await page.getByRole("button", { name: "Send" }).click();
const card = page.getByTestId("boardwake-card");
await expect(card).toBeVisible();
await expect(card).toContainText("Board wake");
await expect(card).toContainText("1 review, 1 filing");
// collapsed by default: ambient awareness, not reading assignment
await expect(page.getByTestId("boardwake-body")).toHaveCount(0);
await expect(card).not.toContainText("029f9f7");
await page.getByTestId("boardwake-toggle").click();
const body = page.getByTestId("boardwake-body");
await expect(body).toBeVisible();
await expect(body).toContainText("#2 Statements page → review by webb");
await expect(body).toContainText("nia filed #5 Follow-up: rate limit");
// the hand-off comment sits behind its own per-row toggle
await expect(body).not.toContainText("029f9f7");
await body.getByRole("button", { name: "show hand-off" }).click();
await expect(body).toContainText("029f9f7");
});
test("declining the split returns feedback to the lead", async ({ page }) => {
await page.goto("/");
await page.getByPlaceholder(/Ask the coworker/).fill("propose the split");
await page.getByRole("button", { name: "Send" }).click();
await page.getByTestId("itemsreq-card").waitFor();
await page.getByRole("button", { name: "Not now" }).click();
await expect(page.getByText(/reworking the split/)).toBeVisible();
});
test("the staffing gate shows named workers, the chat toggle, and the grant sentence", async ({
page,
}) => {
await proposeTeam(page);
const card = page.getByTestId("teamreq-card");
await expect(card).toContainText("Proposed team — 3 workers");
// callnames lead the rows; persona + reason follow
await expect(card).toContainText("nia");
await expect(card).toContainText("swe-worker");
await expect(card).toContainText("implementation");
await expect(card).toContainText("checks");
// the chat checkbox defaults OFF — the user's call, not the lead's
await expect(card.getByTestId("teamreq-chat-toggle")).not.toBeChecked();
await expect(card).toContainText(
"Approving grants the lead create, assign & steer — this team only, revocable.",
);
});
test("enabling chat at the gate adds the # team chat row; posting works with mentions", async ({
page,
}) => {
await proposeTeam(page);
await page.getByTestId("teamreq-chat-toggle").check();
await page.getByTestId("teamreq-approve").click();
await expect(page.getByText(/Team created/)).toBeVisible();
// The chat row lives in the drawer's Team panel now (sessions poll: allow a cycle).
await expect(page.getByTestId("rail-toggle-team")).toBeVisible({ timeout: 12_000 });
await page.getByTestId("rail-toggle-team").click();
const chatRow = page.getByTestId("team-chat-row");
await expect(chatRow).toBeVisible();
await expect(chatRow).toContainText("1"); // unread badge
await chatRow.click();
const view = page.getByTestId("teamchat-view");
await expect(view).toBeVisible();
await expect(view).toContainText("assets bucket is public");
await expect(view.locator(".chat-mention").first()).toHaveText("@nia");
await page.getByTestId("chat-input").fill("ship it current-month only @lead");
await page.getByTestId("chat-send").click();
await expect(view).toContainText("ship it current-month only");
await page.keyboard.press("Escape");
await expect(page.getByTestId("teamchat-view")).toHaveCount(0);
});
test("a sleeping lead shows the strip; Ask for a status wakes it", async ({ page }) => {
await proposeTeam(page);
await page.getByTestId("teamreq-approve").click();
await expect(page.getByText(/Team created/)).toBeVisible();
// open the lead's session — it set a check-in timer, so it's sleeping
await page.locator(".sidebar").getByText("Build the statements page").click();
const strip = page.getByTestId("sleep-strip");
await expect(strip).toBeVisible({ timeout: 12_000 });
await expect(strip).toContainText("Sleeping until");
await expect(strip).toContainText("while the team works");
await page.getByTestId("sleep-status-btn").click();
await expect(page.getByText(/Echo: Quick status check/)).toBeVisible();
});
test("with chat declined at the gate, no chat row renders", async ({ page }) => {
await proposeTeam(page);
await page.getByTestId("teamreq-approve").click();
await expect(page.getByText(/Team created/)).toBeVisible();
await expect(page.getByTestId("rail-toggle-team")).toBeVisible({ timeout: 12_000 });
await page.getByTestId("rail-toggle-team").click();
await expect(page.getByTestId("team-panel")).toBeVisible();
await expect(page.getByTestId("team-chat-row")).toHaveCount(0);
});
test("declining the roster returns the turn to the lead", async ({ page }) => {
await proposeTeam(page);
await page.getByRole("button", { name: "Not now" }).click();
await expect(page.getByText(/tell me how to change the roster/)).toBeVisible();
await expect(page.getByTestId("teamreq-card")).toHaveCount(0);
});
test("approval creates the team; members live in the drawer, RECENT keeps one entry", async ({
page,
}) => {
await proposeTeam(page);
await page.getByTestId("teamreq-approve").click();
await expect(page.getByText(/Team created/)).toBeVisible();
// The drawer grows a collapsed Team section with a member-count chip.
// (Sessions poll every 5s, so allow one full cycle.)
const teamToggle = page.getByTestId("rail-toggle-team");
await expect(teamToggle).toBeVisible({ timeout: 12_000 });
await expect(teamToggle).toContainText("3");
await expect(page.getByTestId("team-panel")).toHaveCount(0); // collapsed by default
// The lead is the SESSION — Progress yields its slot (the board is the lead's
// progress surface).
await expect(page.getByTestId("rail-toggle-progress")).toHaveCount(0);
// Workers never appear as top-level RECENT rows — one entry per team, no expansion.
const sidebar = page.locator(".sidebar");
await expect(sidebar.getByText("Build the statements page")).toBeVisible();
await expect(sidebar.getByText("nia", { exact: true })).toHaveCount(0);
await expect(sidebar.locator("[data-testid^=team-toggle-]")).toHaveCount(0);
// Expanding the Team panel shows member rows: dot + callname + current item.
await teamToggle.click();
const panel = page.getByTestId("team-panel");
await expect(panel).toBeVisible();
await expect(panel.getByTestId("team-row-nia")).toContainText("#1 in progress");
await expect(panel.getByTestId("team-row-webb")).toContainText("idle");
await expect(panel.getByTestId("team-row-checks")).toContainText("#4 blocked");
// A member row is the escape hatch — clicking opens that worker's session, where
// the drawer is a plain worker drawer again (Progress back, no Team panel).
await panel.getByTestId("team-row-nia").click();
await expect(page.getByTestId("rail-toggle-progress")).toBeVisible();
await expect(page.getByTestId("rail-toggle-team")).toHaveCount(0);
});

View File

@@ -0,0 +1,59 @@
// OPE-85: a missing CLI becomes a visible decision, never a silently dropped check.
// The bug this guards (owner-hit 2026-08-13): with gitleaks absent, a security review
// quietly omitted its git-history secret scan — "we couldn't look" rendered as "clean".
import { expect } from "@playwright/test";
import { test } from "./fixtures";
async function ask(page: import("@playwright/test").Page) {
await page.goto("/");
await page.getByPlaceholder(/Ask the coworker/).fill("scan for secrets");
await page.getByRole("button", { name: "Send" }).click();
}
test("request_tool surfaces a card naming the tool, the reason and the pinned version", async ({
page,
}) => {
await ask(page);
const card = page.locator(".dirreq-card");
await expect(card).toContainText("gitleaks");
// The coworker's justification is labeled, not a bare floating quote.
await expect(card).toContainText("Reason: “scan the git history for committed secrets”");
// The fact strip is the product's voice: version, publisher, checksum — kept apart from
// the coworker's quoted reason (mixing them is what made the card confusing, 2026-08-14).
const facts = card.locator(".toolreq-facts");
await expect(facts).toContainText("8.30.1");
// Plain-language consent: who installs (OpenWorker), from where, and the self-install
// alternative — no supply-chain jargon on the card (owner feedback 2026-08-15).
await expect(facts).toContainText(
"OpenWorker installs its own verified copy from github.com/gitleaks — or install it yourself and continue.",
);
// Declining must read as a normal choice that continues the run, not a failure.
await expect(card.getByTestId("toolreq-skip")).toHaveText("Continue without it");
});
test("an event without install metadata fails CLOSED — Install disabled, skip offered", async ({
page,
}) => {
// Owner-hit 2026-08-14: the card offered "pinned build, checksum-verified" for a tool
// with no pinned build; approval could only produce an error. Absence of metadata is NO.
await page.goto("/");
await page.getByPlaceholder(/Ask the coworker/).fill("request an unpinned tool");
await page.getByRole("button", { name: "Send" }).click();
const card = page.locator(".dirreq-card");
await expect(card).toContainText("somescanner");
await expect(card).toContainText(/no verified build/i);
await expect(card.getByTestId("toolreq-install")).toBeDisabled();
await expect(card.getByTestId("toolreq-skip")).toBeEnabled();
});
test("installing runs the check; skipping still reports coverage", async ({ page }) => {
await ask(page);
await page.getByTestId("toolreq-install").click();
await expect(page.locator(".main-scroll")).toContainText("Installed gitleaks");
await page.getByPlaceholder(/Ask the coworker/).fill("scan for secrets");
await page.getByRole("button", { name: "Send" }).click();
await page.getByTestId("toolreq-skip").click();
// The whole point: the skipped check is disclosed, not invisible.
await expect(page.locator(".main-scroll")).toContainText(/Coverage:/);
});

View File

@@ -0,0 +1,83 @@
// FB-004/FB-005: the transcript follows a streaming turn only while the reader is at the
// bottom — scrolling up PINS the viewport (reading must never be yanked away) and surfaces
// a jump-to-latest pill; bubbles grow hover affordances (copy + timestamp) that reveal
// without shifting layout. Driven against the fixtures' slow "stream the epic" turn.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
// The copy test asserts real clipboard writes — grant instead of relying on defaults.
test.use({ permissions: ["clipboard-write"] });
const scrollerState = `(() => {
const el = document.querySelector(".main-scroll");
return el ? { top: el.scrollTop, height: el.scrollHeight, client: el.clientHeight } : null;
})()`;
test("scrolling up mid-stream pins the viewport; jump-to-latest re-engages", async ({ page }) => {
await page.goto("/");
await page.getByText("Draft the launch note").first().click();
const box = page.getByPlaceholder(/Ask the coworker/);
await box.fill("stream the epic");
await box.press("Enter");
// Let the stream outgrow the viewport, then read something "above".
await page.waitForFunction(
() => {
const el = document.querySelector(".main-scroll");
return !!el && el.scrollHeight > el.clientHeight + 400;
},
{ timeout: 10_000 },
);
await page.locator(".main-scroll").evaluate((el) => (el.scrollTop = 0));
// The stream keeps growing below…
const h1 = (await page.evaluate(scrollerState))!.height;
await page.waitForFunction(
(prev) => {
const el = document.querySelector(".main-scroll");
return !!el && el.scrollHeight > prev;
},
h1,
{ timeout: 5_000 },
);
// …but the viewport stays where the reader put it (the old behavior yanked to bottom
// on every delta), and the pill offers the way back.
const pinned = (await page.evaluate(scrollerState))!;
expect(pinned.top).toBeLessThan(50);
await expect(page.getByTestId("jump-to-latest")).toBeVisible();
await page.getByTestId("jump-to-latest").click();
await page.waitForFunction(
() => {
const el = document.querySelector(".main-scroll");
return !!el && el.scrollHeight - el.scrollTop - el.clientHeight < 80;
},
{ timeout: 5_000 },
);
await expect(page.getByTestId("jump-to-latest")).toHaveCount(0);
// Re-engaged: the follow survives the rest of the stream to the turn's end.
await expect(page.getByText("The epic concludes.").first()).toBeVisible({ timeout: 10_000 });
const done = (await page.evaluate(scrollerState))!;
expect(done.height - done.top - done.client).toBeLessThan(80);
});
test("bubbles carry hover copy + timestamp without layout shift", async ({ page }) => {
await page.goto("/");
await page.getByText("Draft the launch note").first().click();
const box = page.getByPlaceholder(/Ask the coworker/);
await box.fill("hello meta");
await box.press("Enter");
await expect(page.getByText("Echo: hello meta", { exact: false }).first()).toBeVisible();
// Live items are stamped client-side, so both bubbles expose the affordance strip.
const userBubble = page.locator(".bubble-user").last();
await userBubble.hover();
const meta = page.getByTestId("bubble-copy");
await expect(meta.first()).toBeVisible();
await expect(page.getByTestId("bubble-ts").first()).toBeVisible();
// Copy actually copies (the fixture page runs with clipboard permission in Chromium).
await meta.first().click();
await expect(page.getByText("Copied").first()).toBeVisible();
});

View File

@@ -0,0 +1,21 @@
// Reconnect-mid-turn (owner catch 2026-08-24, v0.2.0 walkthrough): opening a session
// whose turn is already running server-side never sees a live `turn_start`, so `running`
// must be restored from the ws `ready` payload — otherwise the Stop button and the
// "Waiting for agent" row vanish and the user cannot stop the turn.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
test("opening a session with a live turn shows Stop and the waiting row", async ({ page }) => {
await page.goto("/");
// "Long audit" is below the sidebar's peek cap — expand the list first.
await page.getByRole("button", { name: /Show more/ }).first().click();
await page.getByTitle("Long audit").click();
// ready carried running:true — Stop replaces Send, the waiting row spins.
await expect(page.getByRole("button", { name: /Stop/ })).toBeVisible();
await expect(page.getByText("Waiting for agent...")).toBeVisible();
// An idle session still gets the plain send arrow (running:false path).
await page.getByText("Draft the launch note").first().click();
await expect(page.getByRole("button", { name: /Stop/ })).toHaveCount(0);
});

View File

@@ -0,0 +1,113 @@
// Unattended mode (item 8) — the "Send approvals to Inbox" toggle and its effect on approvals.
// Since §22 the toggle lives at the BOTTOM of the composer's Mode menu (who approves, and when —
// one mental model; the standalone InboxControl left the row). When a session is unattended, an
// approval PARKS to the Inbox instead of surfacing an inline card (the app suppresses the live
// card; the Inbox list itself is covered by inbox.spec.ts). The mocked /v1/sessions/:id/unattended
// is stateful so the toggle persists across a reload.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
// The toggle sits inside the composer's Mode menu (§22).
async function openModeMenu(page) {
await page.getByRole("button", { name: "Mode", exact: true }).click();
await expect(page.getByTestId("mode-menu")).toBeVisible();
}
test("attended (default): a tool request surfaces the inline approval card", async ({ page }) => {
await page.goto("/");
const box = page.getByPlaceholder(/Ask the coworker/);
await box.fill("please run a tool");
await page.getByRole("button", { name: "Send" }).click();
await expect(page.getByText("The coworker wants to run a command.").first()).toBeVisible();
});
test("Send-to-Inbox toggle (in the Mode menu) flips and persists across a reload", async ({
page,
}) => {
await page.goto("/");
await openModeMenu(page);
const sw = page.getByRole("switch", { name: "Send approvals to the Inbox" });
await expect(sw).toHaveAttribute("aria-checked", "false");
await sw.click();
await expect(sw).toHaveAttribute("aria-checked", "true");
// Reload: the stateful endpoint returns the saved flag, so the toggle reads back on.
await page.reload();
await openModeMenu(page);
await expect(page.getByRole("switch", { name: "Send approvals to the Inbox" })).toHaveAttribute(
"aria-checked",
"true",
);
});
test("unattended: a tool request parks (no inline approval card)", async ({ page }) => {
await page.goto("/");
await openModeMenu(page);
await page.getByRole("switch", { name: "Send approvals to the Inbox" }).click();
// The menu's full-screen overlay closes it on any outside click.
await page.mouse.click(5, 5);
const box = page.getByPlaceholder(/Ask the coworker/);
await box.fill("please run a tool");
await page.getByRole("button", { name: "Send", exact: true }).click();
// The turn still starts, but the live approval card is suppressed — the prompt is parked to the
// Inbox instead. Give the (suppressed) card a beat to NOT appear.
await expect(page.getByText("Echo:").first()).toBeVisible().catch(() => {});
await expect(page.getByText("The coworker wants to run a command.")).toHaveCount(0);
});
test("answering the live approval never re-flashes its parked Inbox mirror", async ({ page }) => {
// Every live approval is ALSO parked as a per-session Inbox item (reconnect/remote resolution).
// Tester catch 2026-07-12: after "Allow once", the polled sessionInbox copy was still pending
// for up to a poll cycle, so the docked answer-in-context card flashed the SAME request again.
// Simulate the mirror: any per-session inbox fetch for the live session returns one pending
// approval until the decision lands (the fixtures' fixed items belong to other sessions).
// The real server resolves the mirror synchronously with the decision — only the CLIENT's
// polled copy is stale, which is exactly what this test pins.
let mirrorResolved = false;
await page.route(/\/v1\/inbox\?/, async (route) => {
const q = new URL(route.request().url()).searchParams;
const sid = q.get("session_id");
if (!sid || sid === "wp-3" || sid === "ops-1") return route.fallback();
return route.fulfill({
contentType: "application/json",
body: JSON.stringify({
items: mirrorResolved
? []
: [
{
id: "mirror-1",
session_id: sid,
kind: "approval",
title: "Run `run_shell`?",
body: "requires approval",
state: "pending",
resolution: null,
inbox: "default",
created_at: "2026-07-12 10:00:00",
resolved_at: null,
},
],
}),
});
});
await page.goto("/");
const box = page.getByPlaceholder(/Ask the coworker/);
await box.fill("please run a tool");
await page.getByRole("button", { name: "Send", exact: true }).click();
await expect(page.getByText("The coworker wants to run a command.").first()).toBeVisible();
mirrorResolved = true; // server side resolves with the decision; the stale client copy is the bug
await page.getByRole("button", { name: "Allow once" }).last().click();
// "Never appears" semantics: pre-fix the stale mirror rendered within a frame of the click and
// self-cleared a poll later — so a plain toHaveCount(0) would blink green. Watch the window.
const flashed = await page
.getByText("Run `run_shell`?")
.waitFor({ state: "visible", timeout: 700 })
.then(() => true)
.catch(() => false);
expect(flashed).toBe(false);
await expect(page.getByText("The command ran; 1 file found.")).toBeVisible();
});

View File

@@ -0,0 +1,94 @@
// Token-usage chip (OPE-42): after a turn reports usage, a quiet meter+count chip appears
// in the composer's bottom row; clicking it opens the per-model breakdown popover with the
// context-window fill. The fake agent attaches fixed usage to every echo turn
// (input 1k / output 200 / cache_read 8k / cache_write 800 — 10k per turn), and the
// settings fixture maps the default model to a 200k context window.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
test("usage chip appears after a turn and opens the breakdown popover", async ({ page }) => {
await page.goto("/");
await page.getByText("Draft the launch note").first().click();
// Fresh session: no usage yet — the chip is hidden entirely.
await expect(page.getByTestId("usage-chip")).toHaveCount(0);
const box = page.getByPlaceholder(/Ask the coworker/);
await box.fill("hello");
await box.press("Enter");
await expect(page.getByText("Echo: hello", { exact: false }).first()).toBeVisible({
timeout: 10_000,
});
// Default: no bar — the chip states the in-context size (prompt side of the last
// turn: 1k + 8k + 800 = 9.8k). Session totals are on release hold (owner call
// 2026-08-24): context-window figures only, everywhere.
const chip = page.getByTestId("usage-chip");
await expect(chip).toContainText("9.8k");
// Popover: context fill only (9.8k prompt-side of 200k = 5%) — no totals breakdown.
await chip.click();
const pop = page.getByTestId("usage-popover");
await expect(pop).toBeVisible();
await expect(pop).toContainText("Context window");
await expect(pop).toContainText("9.8k of 200k · 5%");
await expect(pop).not.toContainText("Session totals");
await expect(pop).not.toContainText("Uncached input");
await expect(pop).not.toContainText("tokens");
// Context is a level, not a sum — a second identical turn leaves the chip unchanged.
// The scrim click closes the popover.
await page.mouse.click(10, 10);
await expect(pop).toHaveCount(0);
await box.fill("again");
await box.press("Enter");
await expect(page.getByText("Echo: again", { exact: false }).first()).toBeVisible({
timeout: 10_000,
});
await expect(chip).toContainText("9.8k");
});
test("usage resets on a new session", async ({ page }) => {
await page.goto("/");
await page.getByText("Draft the launch note").first().click();
const box = page.getByPlaceholder(/Ask the coworker/);
await box.fill("hello");
await box.press("Enter");
await expect(page.getByTestId("usage-chip")).toBeVisible({ timeout: 10_000 });
// " New session" wipes the transcript — and the usage accumulation with it.
await page.getByRole("button", { name: /New session/ }).first().click();
await expect(page.getByTestId("usage-chip")).toHaveCount(0);
});
test("Settings toggle turns the context bar on; default is the in-context number", async ({ page }) => {
await page.goto("/");
await page.getByText("Draft the launch note").first().click();
const box = page.getByPlaceholder(/Ask the coworker/);
await box.fill("hello");
await box.press("Enter");
const chip = page.getByTestId("usage-chip");
await expect(chip).toContainText("9.8k", { timeout: 10_000 }); // default: in-context size, no bar
// Turn the bar ON in Settings -> General.
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Settings", exact: true }).click();
await expect(page.getByTestId("context-bar-toggle")).not.toBeChecked();
const [req] = await Promise.all([
page.waitForRequest(
(r) => r.url().endsWith("/v1/settings/context-bar") && r.method() === "POST",
),
page.getByTestId("context-bar-toggle").check(),
]);
expect(req.postDataJSON()).toEqual({ context_bar: true });
// Reload so the app re-reads settings: the chip is now the fill bar, not a number.
await page.goto("/");
await page.getByText("Draft the launch note").first().click();
await page.getByPlaceholder(/Ask the coworker/).fill("hello");
await page.getByPlaceholder(/Ask the coworker/).press("Enter");
const bar = page.getByTestId("usage-chip");
await expect(bar).toBeVisible({ timeout: 10_000 });
await expect(bar).not.toContainText("9.8k");
await expect(bar).toHaveAttribute("title", /Context window 5% full/);
});

22
surfaces/gui/index.html Normal file
View File

@@ -0,0 +1,22 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OpenMesh</title>
<link rel="icon" type="image/svg+xml" href="./src/assets/senmesh-logo.svg" />
<!-- Resolve the theme before first paint (no white flash for dark users).
Must match src/theme.ts: key "openwork-theme", absent/invalid = auto = follow macOS. -->
<script>
try {
var t = localStorage.getItem("openwork-theme");
var dark = t === "dark" || (t !== "light" && window.matchMedia("(prefers-color-scheme: dark)").matches);
document.documentElement.dataset.theme = dark ? "dark" : "light";
} catch (e) {}
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

6431
surfaces/gui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

45
surfaces/gui/package.json Normal file
View File

@@ -0,0 +1,45 @@
{
"name": "openworker-gui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"test": "vitest run",
"e2e": "playwright test",
"e2e:ui": "playwright test --ui",
"e2e:live": "playwright test -c playwright.live.config.ts",
"tauri": "tauri"
},
"dependencies": {
"docx": "^9.7.1",
"i18next": "^26.3.6",
"pdfjs-dist": "^4.10.38",
"pptxgenjs": "^4.0.1",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-i18next": "^17.0.11",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"simple-icons": "^16.26.0",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@playwright/test": "^1.61.1",
"@tauri-apps/cli": "^2.11.2",
"@testing-library/dom": "^10.4.1",
"@testing-library/react": "^16.3.2",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"autoprefixer": "^10.5.2",
"jsdom": "^25.0.1",
"postcss": "^8.5.16",
"tailwindcss": "^3.4.19",
"typescript": "^5.5.3",
"vite": "^5.4.0",
"vitest": "^2.1.9"
}
}

View File

@@ -0,0 +1,26 @@
import { defineConfig, devices } from "@playwright/test";
// E2E harness for the GUI. Tests are hermetic: every /v1 request and the event WebSocket are mocked
// at the network layer (see e2e/fixtures.ts), so they run without the Python backend and never
// mutate real state — safe for CI and for asserting regressions in the interaction flows.
const PORT = 5199;
export default defineConfig({
testDir: "./e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 1 : 0,
reporter: process.env.CI ? "line" : [["list"]],
use: {
baseURL: `http://localhost:${PORT}`,
trace: "on-first-retry",
},
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
webServer: {
// Dev server on a dedicated port so it never collides with a running `npm run dev` (5173).
command: `npm run dev -- --port ${PORT} --strictPort`,
url: `http://localhost:${PORT}`,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
});

View File

@@ -0,0 +1,29 @@
import { defineConfig, devices } from "@playwright/test";
// LIVE smoke config — runs against the REAL backend (openworker-server on :8765) and a REAL model.
// Deliberately separate from playwright.config.ts (testDir ./e2e), so `npm run e2e` and CI never
// pick these up. Run manually with `npm run e2e:live` when the backend is up and a model is set.
// Nondeterministic and costs a few model tokens per run — a confidence smoke, not an assertion gate.
const PORT = 5199;
export default defineConfig({
testDir: "./e2e-live",
fullyParallel: false,
workers: 1,
retries: 0,
reporter: [["list"]],
// Model + tool execution take real time.
timeout: 180_000,
use: {
baseURL: `http://localhost:${PORT}`,
trace: "on-first-retry",
},
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
webServer: {
// The dev server's default API base is 127.0.0.1:8765 — i.e. the real backend (no mocks here).
command: `npm run dev -- --port ${PORT} --strictPort`,
url: `http://localhost:${PORT}`,
reuseExistingServer: true,
timeout: 120_000,
},
});

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

3
surfaces/gui/src-tauri/.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
/target
/gen/schemas
/binaries

5968
surfaces/gui/src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,25 @@
[package]
name = "openworker-desktop"
version = "0.1.0"
description = "OpenWorker desktop shell"
edition = "2021"
rust-version = "1.77"
[lib]
name = "openworker_desktop_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = ["tray-icon"] }
tauri-plugin-dialog = "2"
tauri-plugin-autostart = "2"
tauri-plugin-single-instance = "2"
tauri-plugin-updater = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4"] }
# Kept outside the Tauri shell so another product can depend on the same local STT engine.
ocw-stt = { path = "../../../stt" }

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<!-- Merged into the bundle's Info.plist by Tauri. These usage strings appear inside the macOS
permission prompts (Desktop/Documents/Downloads/Photos), so a prompt the user didn't expect
at least explains itself — the agent's tool runs are what touch these folders, and only when
a task needs them. -->
<plist version="1.0">
<dict>
<key>NSMicrophoneUsageDescription</key>
<string>OpenWorker records only while you use the composer microphone to turn your spoken prompt into editable text. Audio is transcribed locally and is not uploaded.</string>
<key>NSDesktopFolderUsageDescription</key>
<string>A task you run may need to read or save files on your Desktop. OpenWorker never scans this folder on its own.</string>
<key>NSDocumentsFolderUsageDescription</key>
<string>A task you run may need to read or save files in Documents. OpenWorker never scans this folder on its own.</string>
<key>NSDownloadsFolderUsageDescription</key>
<string>A task you run may need to read or save files in Downloads. OpenWorker never scans this folder on its own.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>A task you run may need to read an image from your photo library. OpenWorker never scans your photos on its own.</string>
</dict>
</plist>

View File

@@ -0,0 +1,10 @@
fn main() {
// tauri-build validates every `bundle.resources` path on every build, dev included, but
// `binaries/sidecar` is only staged by the release scripts and `/binaries` is gitignored —
// so a fresh checkout died on `resource path 'binaries/sidecar' doesn't exist`. Dev needs no
// packaged server (`server_bin()` falls back to the venv) and empty resource dirs are
// skipped, so a placeholder is enough.
std::fs::create_dir_all("binaries/sidecar").expect("create the sidecar resource dir");
tauri_build::build()
}

View File

@@ -0,0 +1,15 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capabilities for the main coworker window.",
"windows": ["main"],
"permissions": [
"core:default",
"core:window:allow-hide",
"core:window:allow-show",
"core:window:allow-set-focus",
"core:window:allow-unminimize",
"dialog:default",
"autostart:default"
]
}

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<!-- Hardened-runtime entitlements applied when code-signing the app and its binaries.
disable-library-validation is required by the PyInstaller onefile sidecar: it extracts
the Python shared library (signed by python.org, a different Team ID) at runtime, which
library validation would otherwise refuse to load. Accepted by notarization. -->
<plist version="1.0">
<dict>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<!-- Voice input (§37): hardened-runtime processes need this entitlement to capture the
microphone — without it the SIGNED app is denied by macOS even though dev builds work
(Info.plist's NSMicrophoneUsageDescription is only the prompt text, not the grant). -->
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Some files were not shown because too many files have changed in this diff Show More