OpenWorker: initial import

Imported from andrewyng/aisuite@1b4bbf303e
(contents of its platform/ directory, hoisted to the repo root).
Development history prior to this commit lives in that repository.

Co-authored-by: Devika <devikaverma11@gmail.com>
This commit is contained in:
Rohit C Prasad
2026-07-21 11:09:41 -07:00
co-authored by Devika
commit 2b45018ffa
413 changed files with 93539 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
node_modules/
dist/
*.local
.DS_Store
# Playwright
/test-results/
/playwright-report/
/e2e/_dbg.png
+50
View File
@@ -0,0 +1,50 @@
# 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:
```bash
bash platform/packaging/setup_dev_env.sh # → platform/.venv (server + this repo's 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):
```bash
cd platform
./.venv/bin/coworker-server --cwd /path/to/your/project --port 8765
```
2. **Start the UI:**
```bash
cd platform/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`).
## 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 `platform/.venv/bin/coworker-server` automatically (a
packaged sidecar binary is only produced by the release scripts in `platform/packaging/`).
```bash
cd platform/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: 21 KiB

+43
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 { BACKEND } from "./helpers";
async function backendUp(): Promise<boolean> {
try {
const res = await fetch(`${BACKEND}/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 fetch(`${BACKEND}/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 fetch(`${BACKEND}/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"]));
});
+32
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 coworker-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");
});
+33
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 coworker-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);
}
});
@@ -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.
+64
View File
@@ -0,0 +1,64 @@
import { 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";
/** 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 fetch(`${BACKEND}/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();
}
+33
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 coworker-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();
});
+33
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 coworker-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 });
});
@@ -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 coworker-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);
});
+69
View File
@@ -0,0 +1,69 @@
# 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.
## 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`.
```
+96
View File
@@ -0,0 +1,96 @@
// 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: 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 space")).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("Enabled for this session — tap to mute here").nth(1).click();
await expect(section.getByTestId("access-summary")).toHaveText("Browser, GitHub · 1 folder");
});
+78
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();
});
+80
View File
@@ -0,0 +1,80 @@
// §35 (UX-018): approval cards speak the transcript's language. Routine workspace writes
// are a compact ROW (humanized title, inline args-preview, short "Always allow" 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: "Always allow", exact: true })).toHaveAttribute(
"title",
/for 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 Mac").last()).toBeVisible();
await expect(page.getByText("The coworker wants to run a command.").first()).toBeVisible();
await expect(page.getByRole("button", { name: "Always allow this command" }).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();
});
@@ -0,0 +1,52 @@
// 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("account-row").click();
await page.getByRole("button", { name: "Automations", exact: true }).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();
});
@@ -0,0 +1,126 @@
// 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("account-row").click();
await page.getByRole("button", { name: "Automations", exact: true }).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();
});
+33
View File
@@ -0,0 +1,33 @@
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("account-row").click();
await page.getByRole("button", { name: "Automations", exact: true }).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);
});
+48
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();
});
+59
View File
@@ -0,0 +1,59 @@
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 having sent, the model is now FIXED for this session (§17/§22): the composer picker is
// gone and the fact reads in the topbar's facts subtitle instead.
await expect(page.locator(".dd").filter({ hasText: "Claude Opus" })).toHaveCount(0);
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();
});
@@ -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 });
});
@@ -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,
});
});
+85
View File
@@ -0,0 +1,85 @@
// 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 toggle: lives in Settings, signed-in only, default on, opt-out round-trips", async ({
page,
}) => {
// Signed out: Settings has no toggle at all — nothing is sent, nothing to configure.
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 signIn(page);
await page.getByTestId("account-row").click();
await page.getByTestId("account-menu").getByRole("button", { name: "Settings" }).click();
const toggle = page.getByTestId("telemetry-toggle");
await expect(toggle).toBeChecked({ timeout: 10_000 }); // default-on when signed in
await expect(page.getByText("never your prompts, files, or connector")).toBeVisible();
await toggle.uncheck();
await expect(toggle).not.toBeChecked(); // survives the status re-fetch (persisted)
});
+97
View File
@@ -0,0 +1,97 @@
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 (workspace personas only): the five permission options with the current one
// marked, plus the Unattended/send-to-Inbox toggle at the bottom (§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("Explore read-only, propose a plan")).toBeVisible();
// 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("Full access").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");
});
+62
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
});
+66
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);
});
+43
View File
@@ -0,0 +1,43 @@
import { test, expect } from "./fixtures";
// §16 workspace collapse: the persona FAMILY alone decides the workspace behavior.
// code → an explicit project folder, enforced by the FolderGate (no chat-behind-it escape)
// knowledge → starts orphan on a transparent scratch dir — never gated
// (The mock's Ops persona is knowledge-family with zero sessions, so picking it exercises the
// brand-new-session path, not a resume.)
const personaMenu = (page: import("@playwright/test").Page) => page.locator(".newsplit-menu");
async function startAs(page: import("@playwright/test").Page, persona: RegExp) {
await page.getByLabel("Choose a persona").click();
await personaMenu(page).getByRole("button", { name: persona }).click();
}
test("knowledge persona: new session starts instantly, no folder gate", async ({ page }) => {
await page.goto("/");
await expect(page.getByPlaceholder(/Ask the coworker/)).toBeVisible();
await startAs(page, /Ops/);
await expect(page.locator(".gate-overlay")).toHaveCount(0);
await expect(page.getByPlaceholder(/Ask the coworker/)).toBeVisible();
});
test("code persona: the folder gate blocks until a project is chosen", async ({ page }) => {
await page.goto("/");
await expect(page.getByPlaceholder(/Ask the coworker/)).toBeVisible();
await startAs(page, /Code/);
const gate = page.locator(".gate-overlay");
await expect(gate).toBeVisible();
await expect(gate.getByText("Choose a project folder")).toBeVisible();
// No escape hatch: the gate offers pick-a-folder only (no "switch to Chat" — owner call, §16).
await expect(gate.getByText(/chat/i)).toHaveCount(0);
await gate.getByPlaceholder("/path/to/your/project").fill("/tmp/e2e-project");
await gate.getByRole("button", { name: "Open", exact: true }).click();
// Gate clears, the session is rooted in the chosen folder, and the code composer is live.
await expect(page.locator(".gate-overlay")).toHaveCount(0);
await expect(page.getByPlaceholder(/Ask the coder/)).toBeVisible();
});
File diff suppressed because it is too large Load Diff
+111
View File
@@ -0,0 +1,111 @@
// Settings ▸ Personas ▸ Gallery: the catalog lives in a screen-sized modal opened
// from the Personas page (link → featured carousel + list → in-modal solo page →
// informed install → Done lands back on Personas). Plus the page-level delete
// affordance for non-builtin personas.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
async function openPersonas(page) {
// Personas is launch-flagged off by default — these suites cover the flagged-on flows.
await page.addInitScript(() => localStorage.setItem("ocw.flag.personas", "1"));
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Settings", exact: true }).click();
await page.getByRole("button", { name: "Personas", exact: true }).click();
await expect(page.getByTestId("gallery-link")).toBeVisible();
}
async function openGallery(page) {
await openPersonas(page);
await page.getByTestId("gallery-link").click();
await expect(page.getByTestId("gallery-modal")).toBeVisible();
}
test("slow cloud: skeleton shows while the gallery loads, never a blank body", async ({
page,
}) => {
// The real gallery is a cloud round-trip (Lambda + Dynamo) that can take seconds;
// delay the mocked endpoints to assert the skeleton bridges the gap.
await page.route("**/v1/cloud/status", async (route) => {
await new Promise((r) => setTimeout(r, 1200));
await route.fulfill({ json: { ok: true, signed_in: false } });
});
await openGallery(page);
await expect(page.getByTestId("gallery-loading")).toBeVisible();
await expect(page.getByTestId("gallery-loading")).toContainText("Loading the gallery");
// Resolves into the real body (signed-out prompt here) once the cloud answers.
await expect(page.getByTestId("gallery-signin")).toBeVisible({ timeout: 10_000 });
await expect(page.getByTestId("gallery-loading")).toHaveCount(0);
});
test("signed out: modal prompts for sign-in, manual install path unaffected", async ({ page }) => {
await openGallery(page);
const prompt = page.getByTestId("gallery-signin");
await expect(prompt).toContainText("needs a (free) cloud sign-in");
await expect(prompt).toContainText("always works without an account");
await expect(prompt.getByRole("button", { name: "Sign in" })).toBeVisible();
// Esc closes; the Personas page (with its dir/Git importer) is still there.
await page.keyboard.press("Escape");
await expect(page.getByTestId("gallery-modal")).not.toBeVisible();
await expect(page.getByRole("button", { name: "Install", exact: true })).toBeVisible();
});
test("signed in: featured carousel + list; solo page installs informed; Done returns", async ({
page,
}) => {
await openGallery(page);
await page.getByTestId("gallery-signin").getByRole("button", { name: "Sign in" }).click();
// Featured carousel holds the flagged persona; the list holds both.
const featured = page.getByTestId("gallery-featured");
await expect(featured).toBeVisible({ timeout: 10_000 });
await expect(featured).toContainText("Sales Coworker");
await expect(featured).not.toContainText("Recruiter");
await expect(page.getByTestId("gallery-recruiter")).toContainText("View & install");
await expect(page.getByTestId("gallery-team-teaser")).toContainText("coming soon");
// Search narrows the list.
await page.getByPlaceholder("Search personas").fill("recruit");
await expect(page.getByTestId("gallery-sales")).not.toBeVisible();
await page.getByPlaceholder("Search personas").fill("");
// Solo page: pitch + manifest-derived capabilities BEFORE install.
await page.getByTestId("gallery-sales").click();
const detail = page.getByTestId("gallery-detail");
await expect(detail).toContainText("Walk into every call already knowing the account");
const caps = page.getByTestId("gallery-capabilities");
await expect(caps).toContainText("verified from its manifest");
await expect(caps).toContainText("files, search, todo");
await expect(caps).toContainText("hubspot · core");
await expect(caps).toContainText("read deals and contacts");
await detail.getByRole("button", { name: "Install" }).click();
await expect(detail).toContainText("disabled until you approve and enable it");
// Done closes the modal, landing back on the Personas page.
await detail.getByRole("button", { name: "Done" }).click();
await expect(page.getByTestId("gallery-modal")).not.toBeVisible();
await expect(page.getByTestId("gallery-link")).toBeVisible();
});
test("back link returns from the solo page to the catalog", async ({ page }) => {
await openGallery(page);
await page.getByTestId("gallery-signin").getByRole("button", { name: "Sign in" }).click();
await page.getByTestId("gallery-sales").click({ timeout: 10_000 });
await expect(page.getByTestId("gallery-detail")).toBeVisible();
await page.getByRole("button", { name: "← Gallery" }).click();
await expect(page.getByTestId("gallery-cards")).toBeVisible();
});
test("delete: non-builtin personas removable after confirm; built-ins are not", async ({
page,
}) => {
await openPersonas(page);
// Built-ins expose no delete affordance.
await expect(page.getByTestId("persona-delete-cowork")).toHaveCount(0);
// Non-builtin: trash → inline confirm → row gone (works signed out).
await expect(page.getByText("Acme Notes")).toBeVisible();
await page.getByTestId("persona-delete-acme-notes").click();
await page.getByTestId("persona-delete-confirm-acme-notes").click();
await expect(page.getByText("Acme Notes")).not.toBeVisible();
});
+62
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");
});
+107
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();
});
+84
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");
});
+93
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");
});
+77
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("C0777");
await mirror.getByRole("button", { name: "Set", exact: true }).click();
await expect(mirror).toContainText("slack:C0777");
// Back on Pending, the line reflects the new target immediately.
await page.getByTestId("inbox-tab-pending").click();
await expect(line).toContainText("slack: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");
});
+71
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();
});
+37
View File
@@ -0,0 +1,37 @@
// MCP OAuth quick-add (first server: Granola): the MCP tab offers a curated Connect
// card; connecting adds the server, kicks off the browser sign-in ("signing in…"),
// and the tab's poll flips the row to connected. Sign out returns it to needs_auth.
import { expect } from "@playwright/test";
import { test } from "./fixtures";
async function openMcpTab(page) {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Connectors", exact: true }).click();
await page.getByRole("button", { name: "MCP servers", exact: true }).click();
}
test("granola: quick-add card → sign-in flow → connected → sign out", async ({ page }) => {
await openMcpTab(page);
// Curated card renders while granola isn't configured.
const preset = page.getByTestId("mcp-preset-granola");
await expect(preset).toContainText("Granola");
await expect(preset).toContainText("Meeting notes");
// Connect: adds the server with OAuth pending and starts the browser flow.
await preset.getByRole("button", { name: "Connect" }).click();
await expect(page.getByTestId("mcp-preset-granola")).toHaveCount(0);
const row = page.locator(".space-y-2 > div").filter({ hasText: "granola" }).first();
await expect(row).toContainText("signing in…");
// The 2s status poll flips the mock to connected with its 6 tools.
await expect(row).toContainText("connected", { timeout: 10_000 });
await expect(row).toContainText("6 tools");
await expect(row).toContainText("oauth");
// Sign out forgets tokens; the row needs auth again and offers Sign in.
await row.getByTestId("mcp-signout-granola").click();
await expect(row).toContainText("needs auth");
await expect(row.getByTestId("mcp-signin-granola")).toBeVisible();
});
+51
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.");
});
+156
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();
});
@@ -0,0 +1,87 @@
import { test, expect } from "./fixtures";
// Personas is launch-flagged off by default — this suite covers the flagged-on flows.
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => localStorage.setItem("ocw.flag.personas", "1"));
});
// 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 persona picker and the grouped sidebar.
await page.getByLabel("Choose a persona").click();
const menu = page.locator(".newsplit-menu");
await expect(menu).toBeVisible();
await expect(menu.getByText("Acme Notes")).toHaveCount(0);
await page.locator(".fixed.inset-0.z-20").click(); // close via backdrop
await expect(sidebar.getByText("Acme Notes")).toHaveCount(0);
// Enable it on the Personas page.
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Settings", exact: true }).click();
await page.getByRole("button", { name: "Personas", 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("checkbox", { name: "Enabled" });
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.getByLabel("Choose a persona").click();
await expect(page.locator(".newsplit-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: "Personas", exact: true }).click();
const row = page.locator(".divide-y > div").filter({ hasText: "Ops Coworker" });
const enabled = row.getByRole("checkbox", { name: "Enabled" });
// 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: "Personas", exact: true }).click();
const row = page.locator(".divide-y > div").filter({ hasText: "Code" });
const enabled = row.getByRole("checkbox", { name: "Enabled" });
await enabled.click();
await expect(page.getByTestId("persona-disable-warning-code")).toHaveCount(0);
await expect(enabled).not.toBeChecked();
});
+54
View File
@@ -0,0 +1,54 @@
// 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", "••••••••");
});
+51
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 space").
await expect(dirs.getByText("Temporary space")).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);
});
+88
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/,
);
});
+77
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, persona · model after the first turn; click → persona page", 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 model chip leaves the composer; the facts move up to the subtitle.
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();
const sub = page.getByTestId("session-subtitle");
await expect(sub).toContainText("Coworker · Claude Opus 4.8");
await expect(page.locator(".dd").filter({ hasText: "Claude Opus 4.8" })).toHaveCount(0);
// The subtitle is the session's fixed facts — clicking it opens the coworker (persona) page,
// replacing the old topbar sliders button.
await sub.click();
await expect(page.getByRole("button", { name: "Back", exact: true })).toBeVisible();
});
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);
});
+121
View File
@@ -0,0 +1,121 @@
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; Personas is launch-flagged off.
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/hidden tabs: Files is a General card now; Personas is launch-flagged off.
await expect(page.getByRole("button", { name: "Files", exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Personas", exact: true })).toHaveCount(0);
// 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 launch flag brings the Personas tab back (the gallery/persona suites rely on it).
test("Settings: Personas tab returns behind the launch flag", async ({ page }) => {
await page.addInitScript(() => localStorage.setItem("ocw.flag.personas", "1"));
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Settings", exact: true }).click();
await page.getByRole("button", { name: "Personas", exact: true }).click();
await expect(page.getByText("Add personas")).toBeVisible();
});
// 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();
});
// 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; moved under Models by UX-021): 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: "Models", 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 });
});
+61
View File
@@ -0,0 +1,61 @@
// 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("⌘");
await expect(menu.getByRole("button", { name: "Automations", exact: true })).toBeVisible();
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 (Connectors · MCP only)…
await page.getByTestId("account-row").click();
await page.getByTestId("account-menu").getByRole("button", { name: "Connectors", exact: true }).click();
await expect(page.getByRole("button", { name: "MCP servers" })).toBeVisible();
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();
});
@@ -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);
});
+105
View File
@@ -0,0 +1,105 @@
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.
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 (2)" }).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 collapse under From Slack with the platform icon (§31)", async ({
page,
}) => {
await page.goto("/");
await expect(page.getByTitle("Weekly plan 1")).toBeVisible();
// Collapsed by default with a count; the session row hidden until expanded…
const toggle = page.getByTestId("from-slack-toggle");
await expect(toggle).toContainText("From Slack (1)");
await expect(page.getByTitle("#general — check the deploy?")).toHaveCount(0);
await toggle.click();
const row = page.getByTitle("#general — check the deploy?");
await expect(row).toBeVisible();
// …wearing the Slack logo (hover-hidden cluster, so assert attachment not visibility)…
await expect(
page.getByTestId("from-slack-list").locator('[data-logo="slack"]'),
).toHaveCount(1);
// …and never duplicated into any other list.
await expect(page.getByTitle("#general — check the deploy?")).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);
});
+82
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",
);
});
+82
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("Live");
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);
});
+89
View File
@@ -0,0 +1,89 @@
// 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,
}) => {
// 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" },
tools: [], managed: true, managed_profile: false, mode: "", workspaces: [],
unauthorized: [],
},
],
}),
}),
);
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
});
+10
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();
});
+100
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();
});
@@ -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("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Automations", exact: true }).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: "Always allow", 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: "Always allow", 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);
});
@@ -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();
});
+113
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();
});
+21
View File
@@ -0,0 +1,21 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OpenWorker</title>
<!-- 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>
+6097
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
{
"name": "coworker-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": {
"pdfjs-dist": "^4.10.38",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"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"
}
}
+26
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,
},
});
+29
View File
@@ -0,0 +1,29 @@
import { defineConfig, devices } from "@playwright/test";
// LIVE smoke config — runs against the REAL backend (coworker-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,
},
});
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+3
View File
@@ -0,0 +1,3 @@
/target
/gen/schemas
/binaries
+5967
View File
File diff suppressed because it is too large Load Diff
+24
View File
@@ -0,0 +1,24 @@
[package]
name = "coworker-desktop"
version = "0.1.0"
description = "OpenWorker desktop shell"
edition = "2021"
rust-version = "1.77"
[lib]
name = "coworker_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"
# Kept outside the Tauri shell so another product can depend on the same local STT engine.
ocw-stt = { path = "../../../stt" }
+20
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>
+3
View File
@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}
@@ -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"
]
}
+17
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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 B

Binary file not shown.
+762
View File
@@ -0,0 +1,762 @@
//! OpenWorker desktop shell.
//!
//! Tauri is a thin native window over the existing React SPA. It:
//! 1. picks a free localhost port and starts the Python `coworker-server` as a managed
//! sidecar on that port (so it never clashes with a hand-run server on 8765);
//! 2. injects `window.__COWORKER_HTTP__` / `__COWORKER_WS__` before the SPA loads, so
//! `api.ts` talks to the sidecar (single codebase — the browser build still hits 8765);
//! 3. lives in the system tray: closing the window hides it (keeps MyHelper + the scheduler
//! running); only tray → Quit stops the sidecar;
//! 4. exposes native commands: folder picker, autostart (open-at-login), and keep-awake
//! (caffeinate, so scheduled tasks fire while the Mac is idle).
//!
//! The sidecar inherits this process's environment, so a shell-launched `npm run tauri dev`
//! passes `OPENAI_API_KEY` through. A Finder-launched app has no shell env — there the key
//! comes from the SecretStore (Settings tab), see `coworker.providers.resolve_api_key`.
use std::path::PathBuf;
use std::process::{Child, Command, Stdio};
#[cfg(target_os = "windows")]
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use ocw_stt::{Dictation, DownloadProgress};
use serde::Serialize;
use tauri::{
menu::{Menu, MenuItem},
tray::TrayIconBuilder,
Emitter, Manager, RunEvent, WebviewUrl, WebviewWindowBuilder, WindowEvent,
};
use tauri_plugin_autostart::ManagerExt;
/// The sidecar server child — killed on exit (orphaned servers have bitten us before).
struct ServerProcess(Mutex<Option<Child>>);
/// The active keep-awake guard while keep-awake is on (None when off). Dropping the guard
/// releases the hold (kills `caffeinate` on macOS, clears the execution state on Windows).
struct KeepAwake(Mutex<Option<KeepAwakeGuard>>);
fn free_port() -> u16 {
std::net::TcpListener::bind("127.0.0.1:0")
.and_then(|l| l.local_addr())
.map(|a| a.port())
.unwrap_or(8765)
}
/// Path to the server entrypoint. Resolution order:
/// 1. `COWORKER_SERVER_BIN` env override.
/// 2. The bundled onedir sidecar shipped via Tauri `resources` (production): the
/// `sidecar/` folder lands in Contents/Resources on macOS and in the install dir
/// (next to the app exe) on Windows.
/// 3. Legacy onefile slot: `coworker-server[.exe]` next to the app binary (pre-onedir
/// builds used Tauri externalBin).
/// 4. Dev fallback: the repo venv, relative to this crate (`src-tauri` → `platform/.venv`;
/// `bin/` on POSIX, `Scripts\` on Windows).
fn server_bin() -> PathBuf {
if let Ok(p) = std::env::var("COWORKER_SERVER_BIN") {
return PathBuf::from(p);
}
let exe_name = if cfg!(windows) {
"coworker-server.exe"
} else {
"coworker-server"
};
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
// macOS: Contents/MacOS/<app> → Contents/Resources/sidecar/; Windows: resources
// unpack next to the exe, so <install>/sidecar/.
let mut candidates = vec![dir.join("sidecar").join(exe_name)];
if let Some(contents) = dir.parent() {
candidates.push(contents.join("Resources").join("sidecar").join(exe_name));
}
candidates.push(dir.join(exe_name)); // legacy onefile externalBin slot
for c in candidates {
if c.exists() {
return c;
}
}
}
}
let mut p = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
if cfg!(windows) {
p.push("../../../.venv/Scripts/coworker-server.exe");
} else {
p.push("../../../.venv/bin/coworker-server");
}
p
}
/// Mirror of `coworker.secrets.state_dir()` so the shell and server agree on `desktop.json`.
/// Windows: `%APPDATA%\coworker`; POSIX: `~/.config/coworker`. `COWORKER_STATE_DIR` overrides.
fn state_dir() -> PathBuf {
if let Ok(d) = std::env::var("COWORKER_STATE_DIR") {
return PathBuf::from(d);
}
#[cfg(windows)]
{
if let Ok(appdata) = std::env::var("APPDATA") {
return PathBuf::from(appdata).join("coworker");
}
}
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
PathBuf::from(home).join(".config").join("coworker")
}
fn desktop_prefs_path() -> PathBuf {
state_dir().join("desktop.json")
}
/// The sidecar's log file: `<state_dir>/logs/coworker-server.log`, fresh per
/// launch with the previous run kept as `.old`. None (→ /dev/null) only if the
/// directory can't be created — logging must never block startup.
fn server_log_file() -> Option<std::fs::File> {
let dir = state_dir().join("logs");
std::fs::create_dir_all(&dir).ok()?;
let path = dir.join("coworker-server.log");
if path.exists() {
let _ = std::fs::rename(&path, dir.join("coworker-server.log.old"));
}
std::fs::File::create(&path).ok()
}
fn read_keep_awake_pref() -> bool {
std::fs::read_to_string(desktop_prefs_path())
.ok()
.and_then(|s| serde_json::from_str::<serde_json::Value>(&s).ok())
.and_then(|v| v.get("keep_awake").and_then(|b| b.as_bool()))
.unwrap_or(false)
}
fn write_keep_awake_pref(enabled: bool) {
let path = desktop_prefs_path();
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(
&path,
serde_json::json!({ "keep_awake": enabled }).to_string(),
);
}
// -- keep-awake: hold off idle + system sleep so the scheduler keeps firing -------------------
// Cross-platform behind a uniform `start_keep_awake() -> Option<KeepAwakeGuard>`; dropping the
// guard releases the hold. macOS uses the built-in `caffeinate`; Windows uses the
// SetThreadExecutionState API (a dedicated thread holds ES_CONTINUOUS so the state survives
// regardless of which Tauri worker thread toggled it); other platforms are a no-op.
#[cfg(target_os = "macos")]
struct KeepAwakeGuard(Child);
#[cfg(target_os = "macos")]
impl Drop for KeepAwakeGuard {
fn drop(&mut self) {
let _ = self.0.kill();
}
}
#[cfg(target_os = "macos")]
fn start_keep_awake() -> Option<KeepAwakeGuard> {
Command::new("caffeinate")
.args(["-i", "-s"])
.spawn()
.ok()
.map(KeepAwakeGuard)
}
#[cfg(target_os = "windows")]
extern "system" {
fn SetThreadExecutionState(es_flags: u32) -> u32;
}
#[cfg(target_os = "windows")]
const ES_CONTINUOUS: u32 = 0x8000_0000;
#[cfg(target_os = "windows")]
const ES_SYSTEM_REQUIRED: u32 = 0x0000_0001;
#[cfg(target_os = "windows")]
struct KeepAwakeGuard {
stop: Arc<AtomicBool>,
handle: Option<std::thread::JoinHandle<()>>,
}
#[cfg(target_os = "windows")]
impl Drop for KeepAwakeGuard {
fn drop(&mut self) {
self.stop.store(true, Ordering::SeqCst);
if let Some(h) = self.handle.take() {
let _ = h.join();
}
}
}
#[cfg(target_os = "windows")]
fn start_keep_awake() -> Option<KeepAwakeGuard> {
let stop = Arc::new(AtomicBool::new(false));
let stop_thread = stop.clone();
let handle = std::thread::spawn(move || {
// SetThreadExecutionState is thread-affine and the ES_CONTINUOUS hold is dropped when
// the setting thread exits — so keep this thread alive, re-asserting periodically,
// until asked to stop, then clear the hold from this same thread.
unsafe { SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED) };
while !stop_thread.load(Ordering::SeqCst) {
unsafe { SetThreadExecutionState(ES_CONTINUOUS | ES_SYSTEM_REQUIRED) };
std::thread::sleep(std::time::Duration::from_secs(30));
}
unsafe { SetThreadExecutionState(ES_CONTINUOUS) };
});
Some(KeepAwakeGuard {
stop,
handle: Some(handle),
})
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
struct KeepAwakeGuard;
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
fn start_keep_awake() -> Option<KeepAwakeGuard> {
// No portable built-in inhibitor on Linux; keep-awake is a no-op (the toggle still reflects
// state so the UI behaves, but the OS sleep policy is left to the user).
Some(KeepAwakeGuard)
}
// -- native commands (invoked from the SPA via window.__TAURI__.core.invoke) -----------------
/// Native macOS folder picker for the workspace gate.
#[tauri::command]
async fn pick_folder(app: tauri::AppHandle) -> Option<String> {
use tauri_plugin_dialog::DialogExt;
let (tx, rx) = std::sync::mpsc::channel();
app.dialog().file().pick_folder(move |p| {
let _ = tx.send(p);
});
rx.recv().ok().flatten().map(|fp| fp.to_string())
}
#[tauri::command]
fn get_autostart(app: tauri::AppHandle) -> bool {
app.autolaunch().is_enabled().unwrap_or(false)
}
#[tauri::command]
fn set_autostart(app: tauri::AppHandle, enabled: bool) -> bool {
let m = app.autolaunch();
let _ = if enabled { m.enable() } else { m.disable() };
m.is_enabled().unwrap_or(false)
}
#[tauri::command]
fn get_keep_awake(state: tauri::State<KeepAwake>) -> bool {
state.0.lock().unwrap().is_some()
}
#[tauri::command]
fn set_keep_awake(state: tauri::State<KeepAwake>, enabled: bool) -> bool {
let mut guard = state.0.lock().unwrap();
if enabled {
if guard.is_none() {
*guard = start_keep_awake();
}
} else {
// Dropping the taken guard releases the hold (kills caffeinate / clears the
// Windows execution state).
drop(guard.take());
}
let on = guard.is_some();
write_keep_awake_pref(on);
on
}
#[tauri::command]
fn start_window_drag(window: tauri::WebviewWindow) -> bool {
window.start_dragging().is_ok()
}
// -- local dictation ---------------------------------------------------------------------------
// The actual microphone/model code lives in the Tauri-free `ocw-stt` crate. This shell owns the
// macOS permission prompt and translates the reusable API into React-friendly Tauri commands.
#[derive(Clone, Serialize)]
struct VoiceInputStatus {
recording: bool,
model_installed: bool,
model_verified: bool,
test_passed: bool,
download_in_progress: bool,
model_name: &'static str,
model_bytes: u64,
supported: bool,
device_summary: String,
compatibility_reason: Option<String>,
}
fn voice_input_status(dictation: &Dictation) -> VoiceInputStatus {
let status = dictation.status();
let (supported, device_summary, compatibility_reason) = voice_input_compatibility();
VoiceInputStatus {
recording: status.recording,
model_installed: status.model_installed,
model_verified: status.model_verified,
test_passed: status.test_passed,
download_in_progress: status.download_in_progress,
model_name: status.model_name,
model_bytes: status.model_bytes,
supported,
device_summary,
compatibility_reason,
}
}
#[cfg(target_os = "macos")]
fn voice_input_compatibility() -> (bool, String, Option<String>) {
let version = Command::new("/usr/bin/sw_vers")
.arg("-productVersion")
.output()
.ok()
.filter(|output| output.status.success())
.map(|output| String::from_utf8_lossy(&output.stdout).trim().to_owned())
.unwrap_or_else(|| "unknown version".to_owned());
let major = version
.split('.')
.next()
.and_then(|part| part.parse::<u32>().ok())
.unwrap_or(0);
let apple_silicon = std::env::consts::ARCH == "aarch64";
let supported = apple_silicon && major >= 12;
let architecture = if apple_silicon {
"Apple Silicon"
} else {
"Intel"
};
let summary = format!("macOS {version} · {architecture}");
let reason = if !apple_silicon {
Some("Voice Input currently requires an Apple Silicon Mac (M1 or newer).".to_owned())
} else if major < 12 {
Some("Voice Input requires macOS 12 or newer.".to_owned())
} else {
None
};
(supported, summary, reason)
}
#[cfg(target_os = "windows")]
fn voice_input_compatibility() -> (bool, String, Option<String>) {
let version = Command::new("cmd")
.args(["/C", "ver"])
.output()
.ok()
.map(|output| String::from_utf8_lossy(&output.stdout).trim().to_owned())
.unwrap_or_else(|| "Windows (unknown version)".to_owned());
let build = version
.split(|character: char| !character.is_ascii_digit() && character != '.')
.find(|part| part.matches('.').count() >= 2)
.and_then(|part| part.split('.').nth(2))
.and_then(|part| part.parse::<u32>().ok())
.unwrap_or(0);
let x64 = std::env::consts::ARCH == "x86_64";
let supported = x64 && build >= 19_045;
let reason = if !x64 {
Some("Voice Input currently requires a 64-bit x64 Windows PC.".to_owned())
} else if build < 19_045 {
Some("Voice Input requires Windows 10 22H2 or Windows 11.".to_owned())
} else {
None
};
(supported, format!("{version} · x64"), reason)
}
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
fn voice_input_compatibility() -> (bool, String, Option<String>) {
(
false,
format!("{} · {}", std::env::consts::OS, std::env::consts::ARCH),
Some("Voice Input is currently supported on macOS and Windows.".to_owned()),
)
}
#[tauri::command]
fn get_dictation_status(state: tauri::State<Arc<Dictation>>) -> VoiceInputStatus {
voice_input_status(&state)
}
#[tauri::command]
async fn start_dictation(
state: tauri::State<'_, Arc<Dictation>>,
) -> Result<VoiceInputStatus, String> {
// Off the main thread: opening the input device blocks on macOS's one-time microphone
// permission dialog (and CoreAudio device setup) — a sync command would freeze the UI
// behind the system prompt.
let (supported, _, reason) = voice_input_compatibility();
if !supported {
return Err(
reason.unwrap_or_else(|| "Voice Input is not supported on this device.".to_owned())
);
}
let dictation = state.inner().clone();
tauri::async_runtime::spawn_blocking(move || {
dictation.start()?;
Ok::<VoiceInputStatus, String>(voice_input_status(&dictation))
})
.await
.map_err(|e| format!("Dictation failed to start: {e}"))?
}
#[tauri::command]
async fn stop_dictation(state: tauri::State<'_, Arc<Dictation>>) -> Result<String, String> {
let dictation = state.inner().clone();
tauri::async_runtime::spawn_blocking(move || dictation.stop_and_transcribe())
.await
.map_err(|e| format!("Dictation stopped unexpectedly: {e}"))?
}
#[tauri::command]
fn cancel_dictation(state: tauri::State<Arc<Dictation>>) {
state.cancel();
}
#[tauri::command]
async fn download_dictation_model(
app: tauri::AppHandle,
state: tauri::State<'_, Arc<Dictation>>,
) -> Result<VoiceInputStatus, String> {
let dictation = state.inner().clone();
tauri::async_runtime::spawn_blocking(move || {
dictation.install_default_model_with_progress(|progress: DownloadProgress| {
let _ = app.emit("dictation-download-progress", progress);
})?;
Ok::<VoiceInputStatus, String>(voice_input_status(&dictation))
})
.await
.map_err(|e| format!("Voice model download stopped unexpectedly: {e}"))?
}
#[tauri::command]
fn cancel_dictation_model_download(state: tauri::State<Arc<Dictation>>) {
state.cancel_model_download();
}
#[tauri::command]
async fn verify_dictation_model(
state: tauri::State<'_, Arc<Dictation>>,
) -> Result<VoiceInputStatus, String> {
let dictation = state.inner().clone();
tauri::async_runtime::spawn_blocking(move || {
dictation.verify_default_model()?;
Ok::<VoiceInputStatus, String>(voice_input_status(&dictation))
})
.await
.map_err(|e| format!("Voice model verification stopped unexpectedly: {e}"))?
}
#[tauri::command]
fn mark_dictation_test_passed(
state: tauri::State<Arc<Dictation>>,
) -> Result<VoiceInputStatus, String> {
state.mark_test_passed()?;
Ok(voice_input_status(&state))
}
#[tauri::command]
fn delete_dictation_model(state: tauri::State<Arc<Dictation>>) -> Result<VoiceInputStatus, String> {
state.delete_default_model()?;
Ok(voice_input_status(&state))
}
/// Instantaneous mic loudness (0..1) while a dictation is recording — the composer polls
/// this to draw a real input-driven waveform instead of decorative bars (owner catch,
/// DMG #28 walkthrough).
#[tauri::command]
fn dictation_level(state: tauri::State<Arc<Dictation>>) -> f32 {
state.input_level()
}
fn show_main(app: &tauri::AppHandle) {
if let Some(w) = app.get_webview_window("main") {
let _ = w.unminimize();
let _ = w.show();
let _ = w.set_focus();
}
}
// --- Auto-update (tauri-plugin-updater) -------------------------------------------
// The GUI drives updates through these commands (same invoke bridge as everything
// else — no global plugin JS): check, background pre-download, install. Update
// artifacts are minisign-verified against the pubkey in tauri.conf.json before
// anything is installed; the manifest lives at the endpoints configured there
// (download.openworker.com → GitHub Releases).
#[derive(serde::Serialize)]
struct UpdateInfo {
version: String,
notes: String,
}
#[tauri::command]
async fn check_for_update(app: tauri::AppHandle) -> Result<Option<UpdateInfo>, String> {
use tauri_plugin_updater::UpdaterExt;
let updater = app.updater().map_err(|e| e.to_string())?;
let update = updater.check().await.map_err(|e| e.to_string())?;
Ok(update.map(|u| UpdateInfo {
version: u.version.clone(),
notes: u.body.clone().unwrap_or_default(),
}))
}
/// Update bytes pre-fetched by `download_update`, keyed by version. The GUI kicks the
/// download off as soon as a release is offered, so clicking "Restart to update" installs
/// from memory instead of sitting on a multi-minute download behind a spinner.
struct PendingUpdate(Mutex<Option<(String, Vec<u8>)>>);
#[tauri::command]
async fn download_update(
app: tauri::AppHandle,
pending: tauri::State<'_, PendingUpdate>,
) -> Result<(), String> {
use tauri_plugin_updater::UpdaterExt;
let updater = app.updater().map_err(|e| e.to_string())?;
let Some(update) = updater.check().await.map_err(|e| e.to_string())? else {
return Err("no update available".into());
};
// Periodic re-checks re-invoke this for the same release — the cached bytes stand.
// (Guard scope stays sync: a std MutexGuard must not live across an await.)
{
let slot = pending.0.lock().unwrap();
if slot.as_ref().map(|(v, _)| v == &update.version).unwrap_or(false) {
return Ok(());
}
}
let bytes = update
.download(|_, _| {}, || {})
.await
.map_err(|e| e.to_string())?;
*pending.0.lock().unwrap() = Some((update.version.clone(), bytes));
Ok(())
}
/// Drop the pre-fetched bundle. Invoked on "Later": a dismissed release would
/// otherwise pin tens of MB in memory for the rest of an app run that can last
/// weeks. Changing one's mind just re-downloads.
#[tauri::command]
fn clear_pending_update(pending: tauri::State<'_, PendingUpdate>) {
*pending.0.lock().unwrap() = None;
}
#[tauri::command]
async fn install_update(
app: tauri::AppHandle,
pending: tauri::State<'_, PendingUpdate>,
) -> Result<(), String> {
use tauri_plugin_updater::UpdaterExt;
let updater = app.updater().map_err(|e| e.to_string())?;
let Some(update) = updater.check().await.map_err(|e| e.to_string())? else {
return Err("no update available".into());
};
// Pre-fetched bytes for this exact version install instantly; a stale or missing
// cache falls back to the original blocking download-and-install.
let cached = {
let mut slot = pending.0.lock().unwrap();
match slot.take() {
Some((v, bytes)) if v == update.version => Some(bytes),
_ => None,
}
};
match cached {
Some(bytes) => update.install(bytes).map_err(|e| e.to_string())?,
None => update
.download_and_install(|_, _| {}, || {})
.await
.map_err(|e| e.to_string())?,
}
// Windows never reaches here (the NSIS installer takes over and relaunches).
// macOS: the .app was swapped in place — restart into the new version. The tray
// Exit path's sidecar kill runs via RunEvent, so no orphaned coworker-server.
app.restart();
}
pub fn run() {
let port = free_port();
let http = format!("http://127.0.0.1:{port}");
let ws = format!("ws://127.0.0.1:{port}");
// Debug-format yields a quoted JS string literal.
let inject = format!("window.__COWORKER_HTTP__={http:?};window.__COWORKER_WS__={ws:?};");
tauri::Builder::default()
// MUST be the first plugin: when a second launch happens (e.g. the user relaunches
// while the window is closed-to-tray), this fires in the ALREADY-running instance to
// surface its healthy window, and the second process exits before it can spawn a
// duplicate sidecar — which previously left a window stuck on "Starting coworker…".
.plugin(tauri_plugin_single_instance::init(|app, _argv, _cwd| {
show_main(app);
}))
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.plugin(tauri_plugin_autostart::init(
tauri_plugin_autostart::MacosLauncher::LaunchAgent,
None,
))
.invoke_handler(tauri::generate_handler![
pick_folder,
get_autostart,
set_autostart,
get_keep_awake,
set_keep_awake,
start_window_drag,
get_dictation_status,
start_dictation,
stop_dictation,
cancel_dictation,
download_dictation_model,
cancel_dictation_model_download,
verify_dictation_model,
mark_dictation_test_passed,
delete_dictation_model,
dictation_level,
check_for_update,
download_update,
clear_pending_update,
install_update
])
.setup(move |app| {
// 1. Start the Python server sidecar on the chosen port (inherits our env).
let mut server_cmd = Command::new(server_bin());
server_cmd
.args(["--host", "127.0.0.1", "--port", &port.to_string()])
// The sidecar self-exits if we die abruptly (dev-watcher restart, crash) —
// belt-and-suspenders alongside the RunEvent::ExitRequested kill below.
// The explicit PID matters: under PyInstaller onefile the python process is a
// *grandchild* (bootloader in between), so getppid() never points at us and a
// reparenting check alone leaks both processes on quit.
.env("COWORKER_EXIT_WITH_PARENT", "1")
.env("COWORKER_PARENT_PID", std::process::id().to_string())
// This GUI app has no console, so a console-subsystem child would inherit
// invalid std handles and crash a few seconds in when uvicorn writes its logs
// (the "Starting coworker…" freeze on Windows). Hand it real handles: the
// server's output goes to a log file so field issues are debuggable at all
// ("relay off, no messages" was undiagnosable with everything on /dev/null).
// One file per launch, previous run kept as .old.
.stdin(Stdio::null());
match server_log_file() {
Some(log) => {
if let Ok(err_clone) = log.try_clone() {
server_cmd
.stdout(Stdio::from(log))
.stderr(Stdio::from(err_clone));
} else {
server_cmd.stdout(Stdio::from(log)).stderr(Stdio::null());
}
}
None => {
server_cmd.stdout(Stdio::null()).stderr(Stdio::null());
}
}
// CREATE_NO_WINDOW: the sidecar is a console binary; without this a console window
// would flash when the GUI app spawns it on Windows.
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
server_cmd.creation_flags(0x0800_0000);
}
let child = match server_cmd.spawn() {
Ok(child) => Some(child),
Err(e) => {
eprintln!("[coworker] failed to start server sidecar: {e}");
None
}
};
app.manage(ServerProcess(Mutex::new(child)));
// Restore keep-awake from the last session.
let ka = if read_keep_awake_pref() {
start_keep_awake()
} else {
None
};
app.manage(KeepAwake(Mutex::new(ka)));
app.manage(PendingUpdate(Mutex::new(None)));
// Voice recordings are transient; only the explicitly installed local Whisper model
// lives in the existing application state directory.
app.manage(Arc::new(Dictation::new(state_dir().join("models"))));
// 2. Build the window, injecting the sidecar endpoints before the SPA loads.
// Overlay title bar (macOS): traffic lights float over the edge-to-edge UI.
let mut builder =
WebviewWindowBuilder::new(app, "main", WebviewUrl::App("index.html".into()))
.title("OpenWorker")
.inner_size(1360.0, 900.0)
.min_inner_size(980.0, 640.0)
// Let the WEBVIEW receive OS file drags: Tauri's own drag-drop handler
// otherwise intercepts them, so the composer's HTML5 onDrop (attach by
// dragging a file in) never fired in the desktop shell — browser dev
// worked, DMGs didn't. main.tsx guards against drops outside the
// composer navigating the page.
.disable_drag_drop_handler()
.initialization_script(&inject);
#[cfg(target_os = "macos")]
{
builder = builder
.title_bar_style(tauri::TitleBarStyle::Overlay)
.hidden_title(true)
// Nudge the traffic lights down + in so they sit vertically centered in a
// roomier top strip, aligned with the sidebar toggle and title rather than
// jammed against the top edge.
.traffic_light_position(tauri::LogicalPosition::new(19.0, 24.0));
}
let win = builder.build()?;
// Close-to-tray: hide instead of quitting so the sidecar keeps running.
let w = win.clone();
win.on_window_event(move |event| {
if let WindowEvent::CloseRequested { api, .. } = event {
let _ = w.hide();
api.prevent_close();
}
});
// 3. System tray: Open / Settings / Quit.
let open_i = MenuItem::with_id(app, "open", "Open OpenWorker", true, None::<&str>)?;
let settings_i = MenuItem::with_id(app, "settings", "Settings", true, None::<&str>)?;
let quit_i = MenuItem::with_id(app, "quit", "Quit", true, None::<&str>)?;
let menu = Menu::with_items(app, &[&open_i, &settings_i, &quit_i])?;
// A monochrome template icon (black + alpha, raw RGBA 44×44) so the menu bar tints
// it for light/dark automatically — not the full-color app icon.
let tray_icon = tauri::image::Image::new(include_bytes!("../icons/tray.rgba"), 44, 44);
TrayIconBuilder::new()
.tooltip("OpenWorker")
.icon(tray_icon)
.icon_as_template(true)
.menu(&menu)
.on_menu_event(|app, event| match event.id.as_ref() {
"open" => show_main(app),
"settings" => {
show_main(app);
if let Some(w) = app.get_webview_window("main") {
let _ = w.eval(
"window.dispatchEvent(new CustomEvent('coworker:open-settings'))",
);
}
}
"quit" => app.exit(0),
_ => {}
})
.build(app)?;
Ok(())
})
.build(tauri::generate_context!())
.expect("error while building the OpenWorker desktop app")
.run(|app, event| {
// Also on Exit: belt-and-suspenders in case a quit path reaches teardown without
// a preceding ExitRequested (observed with macOS Cmd+Q under the tray setup).
if matches!(event, RunEvent::ExitRequested { .. } | RunEvent::Exit) {
if let Some(state) = app.try_state::<ServerProcess>() {
if let Some(mut child) = state.0.lock().unwrap().take() {
let _ = child.kill();
}
}
if let Some(state) = app.try_state::<KeepAwake>() {
// Dropping the guard releases the hold (caffeinate kill / execution-state clear).
drop(state.0.lock().unwrap().take());
}
}
});
}
+6
View File
@@ -0,0 +1,6 @@
// Prevent a console window on Windows release builds.
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
coworker_desktop_lib::run();
}
+55
View File
@@ -0,0 +1,55 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "OpenWorker",
"version": "0.1.3",
"identifier": "com.openworker.desktop",
"build": {
"frontendDist": "../dist",
"devUrl": "http://localhost:1420",
"beforeDevCommand": "npm run dev",
"beforeBuildCommand": "npm run build"
},
"app": {
"withGlobalTauri": true,
"windows": [],
"security": {
"csp": null
}
},
"bundle": {
"active": true,
"targets": "all",
"publisher": "OpenWorker",
"icon": [
"icons/32x32.png",
"icons/128x128.png",
"icons/128x128@2x.png",
"icons/icon.icns",
"icons/icon.ico"
],
"resources": {
"binaries/sidecar": "sidecar"
},
"macOS": {
"entitlements": "entitlements.plist",
"minimumSystemVersion": "12.0"
},
"windows": {
"webviewInstallMode": {
"type": "downloadBootstrapper"
},
"nsis": {
"installMode": "currentUser"
}
}
},
"plugins": {
"updater": {
"endpoints": [
"https://download.openworker.com/latest.json",
"https://github.com/andrewyng/aisuite/releases/latest/download/latest.json"
],
"pubkey": "dW50cnVzdGVkIGNvbW1lbnQ6IG1pbmlzaWduIHB1YmxpYyBrZXk6IDVCNzEzRjY5OTkzNUNBNjkKUldScHlqV1phVDl4VzBvTnFLLytzaDkzNVd3WWNuUm8yNE95WTBFNnBtcGF1RENxeTRuNVhQeloK"
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+31
View File
@@ -0,0 +1,31 @@
import type { Attachment } from "./types";
const MAX_BYTES = 10 * 1024 * 1024; // skip files larger than ~10MB
const TEXT_RE =
/\.(txt|md|markdown|csv|tsv|json|ya?ml|log|ini|toml|py|js|ts|tsx|jsx|rs|go|java|c|h|cpp|sh|html?|css|sql|xml)$/i;
// Read a File into an Attachment (image/PDF → data URL, text → inline text). Returns null for
// unsupported types or oversized files. Shared by the composer and the session start panel.
export const isPdfFile = (file: File) =>
file.type === "application/pdf" || /\.pdf$/i.test(file.name);
export function readFile(file: File): Promise<Attachment | null> {
const isImage = file.type.startsWith("image/");
const isPdf = isPdfFile(file);
const isText = !isPdf && (file.type.startsWith("text/") || TEXT_RE.test(file.name));
if ((!isImage && !isPdf && !isText) || file.size > MAX_BYTES) return Promise.resolve(null);
return new Promise((resolve) => {
const reader = new FileReader();
reader.onerror = () => resolve(null);
reader.onload = () =>
resolve(
isImage
? { kind: "image", name: file.name || "image", mime: file.type, data_url: String(reader.result) }
: isPdf
? { kind: "pdf", name: file.name || "file.pdf", mime: "application/pdf", data_url: String(reader.result) }
: { kind: "text", name: file.name || "file.txt", mime: file.type, text: String(reader.result) },
);
if (isImage || isPdf) reader.readAsDataURL(file);
else reader.readAsText(file);
});
}
@@ -0,0 +1,605 @@
// AccessSection — the rail's "what can this session touch" section (§32; absorbs the §23
// Session-settings drawer and retires the topbar row/glance). One collapsible rail section:
// · header: "Access" + a permanent summary ("Slack, GitHub · 2 folders") — the §23 trust
// glance made ambient. Ships collapsed; expanding edits INLINE at rail width (no overlay).
// · Sources — Connected toggles (per-session mute), Recommended (connect-in-context), and the
// two-way connectors' channels drill-down — the drawer's content, recut.
// · Folders — the session's working directories (add/remove, RO/RW gate, branch).
// Owns its data (GET /v1/sessions/{id}/connections + the connector index), like the settings
// row before it. Deep links (intro "Configure ", onboarding "Start working") bump `openKey`
// to expand it and scroll it into view.
import { useCallback, useEffect, useRef, useState } from "react";
import {
CLOUD_CHANGED,
getCloudStatus,
getConnectors,
getRecentChannels,
getSessionConnections,
getSubscriptions,
setSessionConnection,
subscribeChannel,
unsubscribeChannel,
type CloudStatus,
type Connector,
type RecentChannel,
type SessionConnections,
type Subscription,
} from "../api";
import { ConnectorBadge } from "../connectors/ConnectorIcon";
import { indexConnectors, labelFor, visualFor, type ConnectorMap } from "../connectors/visuals";
import { baseName } from "../paths";
import { useRoots } from "../useRoots";
import { AddFolderForm } from "./AddFolderForm";
import { Icon } from "./Icon";
import { ConnectSetup } from "./ManageTabs";
import { RootRow } from "./RootRow";
import { ChannelPicker } from "./SubscriptionsChip";
import { Toggle } from "./Toggle";
// A channel address's platform: "slack:C0123" → "slack"; a bare id or "#mention" defaults to
// slack (the backend's own default when no platform prefix is given).
const platformOf = (channel: string) => (channel.includes(":") ? channel.split(":")[0] : "slack");
const SEC_H = "text-[11px] uppercase tracking-[0.05em] text-faint font-semibold";
const TAG_CORE =
"text-[10px] px-1.5 py-0.5 rounded-full bg-warnSoft/70 text-warnInk border border-warnInk/15";
const BTN_ACCENT = "text-[12px] px-2.5 py-1.5 rounded-lg bg-accent text-white shrink-0";
const BTN_BORDERED =
"text-[12px] px-2.5 py-1.5 rounded-lg border border-line bg-paper hover:border-lineStrong shrink-0";
export function AccessSection({
sessionId,
personaId,
projectScoped,
workspace,
branch,
scratchPrimary,
openKey = 0,
onOpenIntegrations,
}: {
sessionId: string;
personaId?: string;
// Project-scoped (code-family) sessions summarize the folder NAME, not a count.
projectScoped?: boolean;
workspace?: string;
branch?: string | null;
scratchPrimary?: boolean;
// Bumped by deep links ("Configure ", onboarding's Start-working) → expand + scroll here.
openKey?: number;
onOpenIntegrations?: () => void;
}) {
const [open, setOpen] = useState(false);
const [conns, setConns] = useState<SessionConnections | null>(null);
const [byName, setByName] = useState<ConnectorMap>({});
const { roots, busy: rootsBusy, error: rootsError, addRoot, toggleAccess, removeRoot } =
useRoots(sessionId, open ? 1 : 0);
const rootEl = useRef<HTMLElement | null>(null);
const reload = useCallback(() => {
// personaId hint: a brand-new session has no server-side record yet, so without it the
// view would resolve to the DEFAULT persona's defaults/recommends.
getSessionConnections(sessionId, personaId)
.then(setConns)
.catch(() => setConns(null));
}, [sessionId, personaId]);
useEffect(() => {
reload();
}, [reload]);
// The connector index feeds brand colors and gates the "Channels ·" links; refetch on every
// expand so a single failed fetch at mount can't hide them for the session's whole lifetime.
useEffect(() => {
let live = true;
getConnectors()
.then((list) => live && setByName(indexConnectors(list)))
.catch(() => {});
return () => {
live = false;
};
}, [open]);
// Deep link: expand + scroll into view (ignore the mount value).
const seenKey = useRef(openKey);
useEffect(() => {
if (openKey === seenKey.current) return;
seenKey.current = openKey;
setOpen(true);
setTimeout(() => rootEl.current?.scrollIntoView({ block: "nearest" }), 30);
}, [openKey]);
// Child views (connect-in-context / channels drill-down) replace the section body inline.
const [channelsFor, setChannelsFor] = useState<string | null>(null);
const [connectFor, setConnectFor] = useState<Connector | null>(null);
// "+ Add a source…" (§32 addendum): the FULL catalog in-session. The list shows on focus,
// before any typing (FB-012: typing-to-see was a hidden step), and the query filters it
// live; rich browsing (detail pages, connect states) stays on the global Connectors page.
const [adding, setAdding] = useState(false);
const [query, setQuery] = useState("");
// The add flow guarantees the new source is live HERE: the user asked for it in this
// session, so after the connect lands it is also enabled per-session explicitly.
const [addedFrom, setAddedFrom] = useState<string | null>(null);
// Folders mirrors Sources: flat rows + a quiet "+" link that expands the inline form.
const [addingFolder, setAddingFolder] = useState(false);
const [cloud, setCloud] = useState<CloudStatus | null>(null);
useEffect(() => {
if (!connectFor) return;
// null means UNKNOWN (renders as "checking"), never signed-out: a single failed
// fetch here used to demand sign-in from a signed-in user with no way to recover
// (FB-013). Poll while the connect pane is open, keep last-good on failure, and
// listen for the sign-in broadcast so the pane flips the moment login lands.
const load = () => getCloudStatus().then(setCloud).catch(() => {});
load();
const t = setInterval(load, 5000);
window.addEventListener(CLOUD_CHANGED, load);
return () => {
clearInterval(t);
window.removeEventListener(CLOUD_CHANGED, load);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [!!connectFor]);
const [subs, setSubs] = useState<Subscription[]>([]);
const [recent, setRecent] = useState<RecentChannel[]>([]);
const [draft, setDraft] = useState("");
const [addErr, setAddErr] = useState<string | null>(null);
const loadSubs = () => getSubscriptions().then(setSubs).catch(() => setSubs([]));
useEffect(() => {
if (!open) return;
loadSubs();
getRecentChannels().then(setRecent).catch(() => setRecent([]));
}, [open]);
// Collapsing the section also closes any child view — reopening starts at the top level.
useEffect(() => {
if (!open) {
setChannelsFor(null);
setConnectFor(null);
setAdding(false);
setQuery("");
setAddedFrom(null);
setAddingFolder(false);
}
}, [open]);
const toggleSession = async (connector: string, next: boolean) => {
await setSessionConnection(sessionId, connector, next);
reload();
};
const channelsOf = (connector: string) =>
subs.filter((s) => s.session_id === sessionId && platformOf(s.channel) === connector);
const addChannel = async () => {
const raw = draft.trim();
if (!raw || !channelsFor) return;
const channel = raw.includes(":") || raw.startsWith("#") ? raw : `${channelsFor}:${raw}`;
const r = await subscribeChannel(sessionId, channel);
if (!r.ok) {
setAddErr(r.error || "Couldn't add that channel.");
return;
}
setAddErr(null);
setDraft("");
loadSubs();
};
const removeChannel = async (channel: string) => {
await unsubscribeChannel(sessionId, channel);
loadSubs();
};
const connected = conns?.connected ?? [];
const recommended = conns?.recommended ?? [];
const live = connected.filter((c) => c.enabled);
// Catalog list: available, not already in the Connected list (those have toggles above).
// Empty query = the whole catalog (FB-012 — the list renders before any typing); a query
// narrows it on title/name/aliases ("calendar" must surface Outlook, not just Google
// Calendar). Alphabetical so filtering never reorders; the container height-caps it, so
// no count cap here.
const connectedSet = new Set(connected.map((c) => c.connector));
const q = query.trim().toLowerCase();
const results = Object.values(byName)
.filter(
(c) =>
c.available &&
!connectedSet.has(c.name) &&
(!q ||
c.title.toLowerCase().includes(q) ||
c.name.toLowerCase().includes(q) ||
(c.aliases ?? []).some((a) => a.toLowerCase().includes(q))),
)
.sort((a, b) => a.title.localeCompare(b.title));
// The header summary — the §23 glance, permanent: live source names + the folder fact.
const names = live.map((c) => labelFor(c.connector, byName));
const sourcesPart =
names.length === 0
? "no sources"
: names.length <= 2
? names.join(", ")
: `${names.slice(0, 2).join(", ")} +${names.length - 2}`;
const folderPart = projectScoped
? baseName(workspace || roots.find((r) => r.primary)?.path || "") || null
: roots.length > 0
? `${roots.length} folder${roots.length === 1 ? "" : "s"}`
: null;
const summary = folderPart ? `${sourcesPart} · ${folderPart}` : sourcesPart;
return (
<section className="rail-section" ref={rootEl} data-testid="access-section">
<div className="rail-section-head">
<button className="rail-section-toggle" onClick={() => setOpen((v) => !v)} data-testid="access-toggle">
<Icon name={open ? "chevronDown" : "chevronRight"} size={14} className="rail-chev" />
<span>Access</span>
<span
className="ml-auto min-w-0 truncate text-[11px] font-normal text-faint"
data-testid="access-summary"
title={summary}
>
{summary}
</span>
</button>
</div>
{open && (
<div className="rail-section-body" role="region" aria-label="Session access">
{connectFor ? (
<ConnectInline
c={connectFor}
cloud={cloud}
onDone={() => {
const name = connectFor.name;
setConnectFor(null);
if (addedFrom === name) {
// Added from THIS session's panel → also enable it here explicitly (a
// catalog connector need not be in the persona's default-on set).
setAddedFrom(null);
setSessionConnection(sessionId, name, true)
.catch(() => {})
.finally(reload);
return;
}
reload();
}}
onBack={() => {
setConnectFor(null);
setAddedFrom(null);
}}
/>
) : channelsFor ? (
<ChannelsInline
label={labelFor(channelsFor, byName)}
channels={channelsOf(channelsFor)}
recent={recent}
draft={draft}
onDraft={(v) => {
setDraft(v);
setAddErr(null);
}}
onAdd={addChannel}
error={addErr}
onRemove={removeChannel}
onBack={() => setChannelsFor(null)}
/>
) : (
<div className="space-y-4">
{/* Sources — each toggle is a per-session override (mute for THIS session only). */}
<div>
<div className={`${SEC_H} mb-1.5`}>Sources</div>
{connected.length === 0 && (
<div className="text-[12px] text-faint py-0.5">
No connectors enabled for this session.
</div>
)}
<div className="space-y-1">
{connected.map((c) => (
<div className="flex items-center gap-2 py-1" key={c.connector}>
<ConnectorBadge connector={visualFor(c.connector, "connector", byName)} size={24} />
<div className="min-w-0 flex-1">
<div className="text-[12.5px] font-medium leading-tight truncate">
<span>{labelFor(c.connector, byName)}</span>
{c.detail && <span className="text-faint font-normal"> · {c.detail}</span>}
</div>
{byName[c.connector]?.channels && (
<button
className="inline-flex items-center gap-0.5 text-[11px] text-accent hover:underline"
onClick={() => {
setDraft("");
setChannelsFor(c.connector);
}}
>
Channels · {channelsOf(c.connector).length}
<Icon name="chevronRight" size={10} />
</button>
)}
</div>
<Toggle
checked={c.enabled}
onChange={(next) => toggleSession(c.connector, next)}
title="Enabled for this session — tap to mute here"
/>
</div>
))}
</div>
{connected.length > 0 && (
<p className="text-[10.5px] text-faint mt-1 leading-snug">
Off mutes it for <b>this session only</b> the connector stays connected.
</p>
)}
{/* §32 addendum (owner ask 2026-07-13; FB-012): the catalog's long tail,
in-session. A quiet row that becomes a typeahead: full list on focus,
filter as you type. */}
{adding ? (
<div className="mt-1.5">
<input
className="w-full px-2.5 py-1.5 rounded-lg border border-line bg-panel text-[12.5px] outline-none focus:border-accent"
placeholder="Search connectors…"
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Escape") {
setAdding(false);
setQuery("");
}
}}
autoFocus
data-testid="access-add-search"
/>
{results.length === 0 && (
// Also covers a failed/empty catalog fetch: an open picker must never
// be silently blank — point at the Connectors page either way.
<div className="text-[11.5px] text-faint mt-1.5 px-0.5">
No match see all on the Connectors page below.
</div>
)}
<div className="mt-1 max-h-64 overflow-y-auto">
{results.map((c) => (
<button
key={c.name}
className="w-full flex items-center gap-2 py-1.5 px-0.5 rounded-lg text-left hover:bg-paper"
data-testid={`access-add-${c.name}`}
onClick={() => {
setAdding(false);
setQuery("");
setAddedFrom(c.name);
setConnectFor(c);
}}
>
<ConnectorBadge connector={visualFor(c.name, "connector", byName)} size={22} />
<span className="min-w-0 flex-1">
<span className="block text-[12.5px] font-medium leading-tight">
{c.title}
</span>
<span className="block text-[11px] text-faint truncate">{c.blurb}</span>
</span>
<Icon name="chevronRight" size={11} className="text-faint shrink-0" />
</button>
))}
</div>
</div>
) : (
<button
className="mt-1 text-[12px] text-accent hover:underline text-left"
onClick={() => setAdding(true)}
data-testid="access-add-source"
>
+ Add a source
</button>
)}
</div>
{recommended.length > 0 && (
<div>
<div className={`${SEC_H} mb-1.5`}>Recommended</div>
<div className="space-y-1">
{recommended.map((r) => (
<div className="flex items-center gap-2 py-1" key={r.connector}>
<ConnectorBadge connector={visualFor(r.connector, "connector", byName)} size={24} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 text-[12.5px] font-medium leading-tight">
<span className="truncate">{labelFor(r.connector, byName)}</span>
{r.tier === "core" && <span className={TAG_CORE}>core</span>}
</div>
<div className="text-[11px] text-faint truncate" title={r.reason}>
{r.reason}
</div>
</div>
<button
className={r.tier === "core" ? BTN_ACCENT : BTN_BORDERED}
onClick={() => {
// Connect IN CONTEXT when we ship this connector; unknown refs
// (no descriptor) still fall back to the global page.
const desc = byName[r.connector];
if (desc) setConnectFor(desc);
else onOpenIntegrations?.();
}}
>
Connect
</button>
</div>
))}
</div>
</div>
)}
{/* Working directories — standing session config (§22/§23 lineage). Flat rows +
a quiet "+" link, structurally identical to Sources (owner ask 2026-07-13:
the old drawer's card wrapper read too heavy in the rail). */}
<div data-testid="drawer-directories">
<div className={`${SEC_H} mb-1.5`}>Folders</div>
<div className="-mx-1.5">
{roots.map((r) => (
<RootRow
key={r.path}
root={r}
busy={rootsBusy}
scratchPrimary={scratchPrimary}
branch={r.primary ? branch : undefined}
onToggle={toggleAccess}
onRemove={removeRoot}
/>
))}
</div>
{addingFolder ? (
<div className="mt-1.5">
<AddFolderForm
onAdd={addRoot}
busy={rootsBusy}
startOpen
onDismiss={() => setAddingFolder(false)}
/>
</div>
) : (
<button
className="mt-1 text-[12px] text-accent hover:underline text-left"
onClick={() => setAddingFolder(true)}
>
+ Give access to a folder
</button>
)}
{rootsError && <div className="roots-err">{rootsError}</div>}
</div>
<button
className="text-[12px] text-accent font-medium hover:underline text-left"
onClick={() => onOpenIntegrations?.()}
>
Manage all connectors (global)
</button>
</div>
)}
</div>
)}
</section>
);
}
// Connect-in-context (§32 child view): the same ConnectSetup the global Connectors page uses,
// hosted inline in the section so connecting never navigates away. Managed connects complete
// out-of-band (browser → broker → sidecar), so poll until the connector flips.
function ConnectInline({
c,
cloud,
onDone,
onBack,
}: {
c: Connector;
cloud: CloudStatus | null;
onDone: () => void;
onBack: () => void;
}) {
useEffect(() => {
const t = setInterval(async () => {
try {
const list = await getConnectors();
if (list.find((x) => x.name === c.name)?.connected) onDone();
} catch {
/* poll again */
}
}, 2500);
return () => clearInterval(t);
}, [c.name, onDone]);
return (
<div>
<button
className="inline-flex items-center gap-1 text-[12px] text-faint hover:text-ink mb-2"
onClick={onBack}
aria-label="Back to sources"
>
<Icon name="arrowLeft" size={13} /> Connect {c.title}
</button>
{c.blurb && <p className="text-[12px] text-muted mb-1 leading-relaxed">{c.blurb}</p>}
<div className="-mx-2">
<ConnectSetup c={c} cloud={cloud} onConnected={onDone} />
</div>
{/* Scope semantics, stated once (owner ask 2026-07-13): connecting is account-level,
the toggle above is what scopes it to a session. */}
<p className="text-[10.5px] text-faint mt-2 leading-snug">
Connecting makes {c.title} available to all your coworkers the toggle in this list
controls just this session.
</p>
</div>
);
}
// The per-connector channels drill-down (§32 child view): which channels THIS session listens
// to on a two-way messaging connector (Slack/Telegram).
function ChannelsInline({
label,
channels,
recent,
draft,
onDraft,
onAdd,
error,
onRemove,
onBack,
}: {
label: string;
channels: Subscription[];
recent: RecentChannel[];
draft: string;
onDraft: (v: string) => void;
onAdd: () => void;
error?: string | null;
onRemove: (channel: string) => void;
onBack: () => void;
}) {
return (
<div>
<button
className="inline-flex items-center gap-1 text-[12px] text-faint hover:text-ink mb-2"
onClick={onBack}
aria-label="Back to sources"
>
<Icon name="arrowLeft" size={13} /> {label} channels
</button>
<div className={`${SEC_H} mb-1.5`}>Subscribed channels · {channels.length}</div>
{channels.length === 0 ? (
<div className="text-[12px] text-faint py-0.5">
Not listening to any {label} channel yet.
</div>
) : (
<div className="space-y-1">
{channels.map((s) => (
<div className="flex items-center gap-1.5 py-1" key={s.channel}>
<Icon name="plug" size={13} className="text-muted shrink-0" />
<span className="min-w-0 flex-1 text-[12.5px] truncate" title={s.channel}>
{s.channel_name ? `#${s.channel_name}` : s.channel}
</span>
{s.collision && (
<span
className="text-[10.5px] text-warnInk bg-warnSoft/70 border border-warnInk/15 rounded px-1 shrink-0"
title="This channel is also this session's Inbox-routing target — inbound and outbound collide."
>
</span>
)}
<button
className="w-5 h-5 grid place-items-center text-faint hover:text-danger shrink-0"
title="Stop listening"
onClick={() => onRemove(s.channel)}
>
×
</button>
</div>
))}
</div>
)}
<div className={`${SEC_H} mt-3 mb-1.5`}>Add a channel</div>
<div className="flex items-center gap-1.5">
<ChannelPicker value={draft} onChange={onDraft} recent={recent} onSubmit={onAdd} />
<button className={BTN_ACCENT} disabled={!draft.trim()} onClick={onAdd}>
Add
</button>
</div>
{error && (
<p className="text-[11px] text-warnInk mt-1.5 leading-snug" data-testid="channel-add-error">
{error}
</p>
)}
<p className="text-[10.5px] text-faint mt-1.5 leading-snug">
The agent receives messages posted to these channels. Removing one stops this session
from listening the connector stays connected.
</p>
</div>
);
}
@@ -0,0 +1,87 @@
import { useState } from "react";
import { chooseFolder } from "../tauri";
import { Icon } from "./Icon";
// A single "Give access to a folder" affordance. Collapsed it's one button; expanded it's a path
// field (Browse on desktop, paste anywhere) + an "Allow writing" checkbox that's OFF by default —
// so access is read-only unless explicitly granted. Used by the composer chip and the start panel.
export function AddFolderForm({
onAdd,
busy,
compact,
startOpen,
onDismiss,
}: {
onAdd: (path: string, writable: boolean) => Promise<boolean> | boolean | void;
busy?: boolean;
compact?: boolean;
// Render the form expanded immediately (the caller owns the trigger); Cancel/success then
// notify via onDismiss so the caller can collapse it.
startOpen?: boolean;
onDismiss?: () => void;
}) {
const [open, setOpen] = useState(!!startOpen);
const [path, setPath] = useState("");
const [writable, setWritable] = useState(false);
const reset = () => {
setOpen(false);
setPath("");
setWritable(false);
onDismiss?.();
};
const browse = async () => {
const p = await chooseFolder();
if (p) setPath(p);
};
const submit = async () => {
if (!path.trim()) return;
const ok = await onAdd(path.trim(), writable);
if (ok !== false) reset();
};
if (!open) {
return (
<button className={"addfolder-trigger" + (compact ? " compact" : "")} onClick={() => setOpen(true)}>
<Icon name="folderPlus" size={15} /> Give access to a folder
</button>
);
}
return (
<div className="addfolder-form">
<div className="addfolder-row">
<input
className="addfolder-path"
autoFocus
placeholder="Choose or paste a folder path…"
value={path}
spellCheck={false}
onChange={(e) => setPath(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") submit();
else if (e.key === "Escape") reset();
}}
/>
<button className="btn icon-only" onClick={browse} title="Choose location" aria-label="Choose location">
<Icon name="folder" size={15} />
</button>
</div>
<div className="addfolder-actions">
<label className="addfolder-write" title="Off = read-only. Tick to let the agent write here.">
<input type="checkbox" checked={writable} onChange={(e) => setWritable(e.target.checked)} />
Allow writes
</label>
<span className="spacer" />
<button className="btn" onClick={reset}>
Cancel
</button>
<button className="btn primary" disabled={busy || !path.trim()} onClick={submit}>
Add
</button>
</div>
</div>
);
}
@@ -0,0 +1,222 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { ApprovalCard } from "./ApprovalCard";
import { InboxItemCard } from "./InboxItemCard";
import type { Item } from "../types";
import type { InboxItem } from "../api";
type ApprovalItem = Extract<Item, { kind: "approval" }>;
const RUN_TASK = { id: "task-1", title: "Weekly digest" };
const sendApproval = (extra: Partial<ApprovalItem> = {}): ApprovalItem => ({
kind: "approval",
name: "send_message",
args: { target: "slack:T1/C1", text: "digest" },
reason: "requires approval",
category: "messaging",
...extra,
});
afterEach(cleanup);
describe("ApprovalCard — standing scoped approvals (§25)", () => {
it("offers Allow every time only with BOTH a run context and an eligible target", () => {
const onApprove = vi.fn();
// Run context + standing target → offered (and it replaces the session-scoped button).
render(
<ApprovalCard
item={sendApproval({ standingTarget: "slack:T1/C1" })}
onApprove={onApprove}
runTask={RUN_TASK}
/>,
);
fireEvent.click(screen.getByText("Allow every time"));
expect(onApprove).toHaveBeenCalledWith("always_task");
expect(screen.queryByText("Always allow")).toBeNull();
cleanup();
// No run context (a plain session) → never offered.
render(
<ApprovalCard item={sendApproval({ standingTarget: "slack:T1/C1" })} onApprove={vi.fn()} />,
);
expect(screen.queryByText("Allow every time")).toBeNull();
cleanup();
// Run context but no eligible target (e.g. run_shell) → never offered.
render(
<ApprovalCard
item={sendApproval({ name: "run_shell", args: { command: "ls" }, standingTarget: undefined })}
onApprove={vi.fn()}
runTask={RUN_TASK}
/>,
);
expect(screen.queryByText("Allow every time")).toBeNull();
});
it("renders the create_scheduled_task consent proposal: reads disclose, writes grant", () => {
render(
<ApprovalCard
item={sendApproval({
name: "create_scheduled_task",
args: {
title: "Weekly digest",
instructions: "post it",
cron: "0 9 * * 1",
permissions: [
{ tool: "send_message", target: "slack:T1/C1", access: "write" },
{ tool: "github_list_commits", target: "rohit/agent-platform", access: "read" },
],
},
})}
onApprove={vi.fn()}
/>,
);
const grants = screen.getByTestId("approval-grants");
expect(grants.textContent).toContain("slack:T1/C1");
expect(grants.textContent).toContain("always allowed once you approve");
expect(grants.textContent).toContain("rohit/agent-platform");
expect(grants.textContent).toContain("read-only");
// The raw permissions JSON must not also dump into the args line.
expect(screen.queryByText(/permissions=/)).toBeNull();
});
});
describe("ApprovalCard — §35 shapes", () => {
it("routine file writes render as a compact row: humanized title, inline preview, Allow → once", () => {
const onApprove = vi.fn();
render(
<ApprovalCard
item={sendApproval({
name: "write_file",
args: { path: "src/fetch_data.py", content: "import json\nimport urllib\nx=1\ny=2\nz=3\ndone=1" },
category: undefined,
})}
onApprove={onApprove}
/>,
);
const row = screen.getByTestId("approval-row");
expect(row.textContent).toContain("Write ");
expect(row.textContent).toContain("fetch_data.py");
expect(screen.queryByText(/Permission required/i)).toBeNull();
// Preview expands INLINE from the tool args (the file doesn't exist yet).
expect(screen.queryByText(/import json/)).toBeNull();
fireEvent.click(screen.getByText(/preview/));
expect(screen.getByText(/import json/)).toBeTruthy();
expect(screen.getByText("show all 6 lines")).toBeTruthy();
fireEvent.click(screen.getByText("Allow"));
expect(onApprove).toHaveBeenCalledWith("once");
});
it("send_file gets the full external card: destination title, file chip, leaves-the-Mac note", () => {
render(
<ApprovalCard
item={sendApproval({
name: "send_file",
args: { target: "slack:T1/C9:1700.1", path: "out/report.pdf", comment: "here you go" },
})}
onApprove={vi.fn()}
/>,
);
expect(screen.getByText(/Send a file to/).textContent).toContain("C9");
expect(screen.getByText(/leaves this Mac → Slack/)).toBeTruthy();
expect(screen.getByText(/report\.pdf/)).toBeTruthy();
expect(screen.getByText(/here you go/)).toBeTruthy();
expect(screen.getByText("Allow once")).toBeTruthy();
});
it("long single-paragraph send_message text is clamped, expandable, and never a wall", () => {
// Owner repro 2026-07-15: a one-paragraph Slack digest (no newlines) blew the card
// up to full-transcript height — the preview clamped by LINES only.
const digest = "aisuite last 24 hours of work: five PRs merged covering streaming, multimodal input, Slack improvements, human attribution, and formatting. ".repeat(8);
render(<ApprovalCard item={sendApproval({ args: { target: "slack:T1/C1", text: digest } })} onApprove={vi.fn()} />);
const prev = document.querySelector(".approval-prev") as HTMLElement;
expect(prev.textContent!.length).toBeLessThan(500);
fireEvent.click(screen.getByText("show the full message"));
expect(document.querySelector(".approval-prev")!.textContent!.length).toBeGreaterThan(1000);
expect(screen.getByText("show less")).toBeTruthy();
});
it("short send_message text keeps the inline quote (no preview box)", () => {
render(<ApprovalCard item={sendApproval()} onApprove={vi.fn()} />);
expect(screen.getByText(/“digest”/)).toBeTruthy();
expect(document.querySelector(".approval-prev")).toBeNull();
});
it("run_shell titles with the model's description and previews the command", () => {
render(
<ApprovalCard
item={sendApproval({
name: "run_shell",
args: { command: "python3 fetch.py > data.json", description: "Fetch semiconductor stock data" },
category: undefined,
})}
onApprove={vi.fn()}
/>,
);
expect(screen.getByText(/Run a command — fetch semiconductor stock data/)).toBeTruthy();
expect(screen.getByText(/python3 fetch\.py/)).toBeTruthy();
expect(screen.getByText(/stays on this Mac/)).toBeTruthy();
expect(screen.getByText("Always allow this command")).toBeTruthy();
});
});
describe("InboxItemCard — Allow every time on parked run approvals", () => {
const baseItem = (data?: Record<string, any>): InboxItem => ({
id: "i1",
session_id: "__run__r1",
kind: "approval",
title: "Run `send_message`?",
body: "target: slack:T1/C1",
state: "pending",
resolution: null,
inbox: "default",
created_at: "",
resolved_at: null,
data,
});
it("shows the button only when the item carries the task binding + target", () => {
const onResolve = vi.fn();
render(
<InboxItemCard
item={baseItem({ task_id: "task-1", task_title: "Weekly digest", standing_target: "slack:T1/C1" })}
onResolve={onResolve}
/>,
);
fireEvent.click(screen.getByText("Allow every time"));
expect(onResolve).toHaveBeenCalledWith("i1", "always_task");
cleanup();
// A plain unattended-session approval (no task data) keeps Approve/Deny only.
render(<InboxItemCard item={baseItem()} onResolve={vi.fn()} />);
expect(screen.queryByText("Allow every time")).toBeNull();
expect(screen.getByText("Approve")).toBeTruthy();
expect(screen.getByText("Deny")).toBeTruthy();
});
it("parked approvals with tool data wear the §35 dress — same dialect as the live card", () => {
const onResolve = vi.fn();
render(
<InboxItemCard
item={baseItem({
tool: "write_file",
arguments: { path: "src/fetch_data.py", content: "import json\nx = 1" },
})}
onResolve={onResolve}
/>,
);
// Humanized title + preview from the args; the raw "Run `write_file`?" title is gone.
expect(screen.getByText("fetch_data.py")).toBeTruthy();
expect(screen.queryByText("Run `send_message`?")).toBeNull();
expect(screen.getByText(/import json/)).toBeTruthy();
expect(screen.getByText(/stays on this Mac/)).toBeTruthy();
// §35 labels; resolution vocabulary unchanged (works on every approver path).
fireEvent.click(screen.getByText("Allow once"));
expect(onResolve).toHaveBeenCalledWith("i1", "allow");
// Old rows without tool data keep the legacy treatment (covered above).
});
});
@@ -0,0 +1,287 @@
import { useState } from "react";
import type { ApprovalDecision, Item } from "../types";
import { humanizeApprovalTitle, type HumanLine } from "../humanize";
import { Icon } from "./Icon";
export function shortArgs(args: any): string {
if (!args || typeof args !== "object") return "";
return Object.entries(args)
.map(([k, v]) => {
let s = typeof v === "string" ? v : JSON.stringify(v);
if (s.length > 96) s = s.slice(0, 95) + "...";
return `${k}=${s.replace(/\n/g, " ")}`;
})
.join(" ");
}
// Human verbs kept for the §25 grant lines (the card title now comes from humanize.ts).
const TOOL_VERBS: Record<string, string> = {
write_file: "Write a file",
replace_in_file: "Edit a file",
apply_patch: "Apply a patch",
apply_unified_diff: "Apply a patch",
run_shell: "Run a command",
send_message: "Send a message",
send_file: "Send a file",
};
// §35: routine workspace writes render as a compact ROW; everything else is a full card.
const FILE_WRITES = new Set(["write_file", "replace_in_file", "apply_patch", "apply_unified_diff"]);
// Actions that leave the Mac get the warm border + explicit destination note.
const EXTERNAL = new Set(["send_message", "send_file"]);
type ApprovalItem = Extract<Item, { kind: "approval" }>;
// A `permissions` proposal on the create_scheduled_task consent card (§25): reads are
// disclosure lines, writes are the standing grants the approval mints.
interface PermissionLine {
tool: string;
target: string;
access: string;
}
function permissionLines(args: any): PermissionLine[] {
const raw = args?.permissions;
if (!Array.isArray(raw)) return [];
return raw
.filter((p) => p && typeof p === "object" && p.tool && p.target)
.map((p) => ({ tool: String(p.tool), target: String(p.target), access: String(p.access || "read") }));
}
export function TitleText({ line }: { line: HumanLine }) {
return (
<span className="approval-title">
{line.pre}
{line.obj && <b>{line.obj}</b>}
{line.post}
</span>
);
}
// Plain-words scope note (replaces the "local action" badge): where does this act?
// Shared with the parked-approval card (InboxItemCard) so both dialects match (§35).
export function scopeNote(
name: string,
args: any,
category?: string,
): { text: string; external: boolean } {
if (category === "connector") return { text: "acts on a connected service", external: true };
if (EXTERNAL.has(name)) {
const platform = String(args?.target ?? "").split(":")[0];
const names: Record<string, string> = { slack: "Slack", telegram: "Telegram" };
return { text: `leaves this Mac → ${names[platform] || platform || "a connected chat"}`, external: true };
}
const overwrite = name === "write_file" && args?.overwrite;
return { text: "stays on this Mac" + (overwrite ? " · overwrites the existing file" : ""), external: false };
}
// The proposed content/command, straight from the tool call's ARGS — the file/action
// doesn't exist yet, so no viewer could show it (§35; see UX-018 mock note).
// Clamps by CHARACTERS as well as lines: a one-paragraph Slack digest has no
// newlines at all and once ballooned the card to full-transcript height.
const PREVIEW_LINES = 5;
const PREVIEW_CHARS = 420;
export function PreviewBlock({ text, mono = true }: { text: string; mono?: boolean }) {
const [all, setAll] = useState(false);
const lines = text.split("\n");
const clipped = lines.length > PREVIEW_LINES || text.length > PREVIEW_CHARS;
let shown = text;
if (!all && clipped) {
shown = lines.slice(0, PREVIEW_LINES).join("\n");
if (shown.length > PREVIEW_CHARS) shown = shown.slice(0, PREVIEW_CHARS).trimEnd() + "…";
}
return (
<div className={"approval-prev" + (mono ? "" : " prose")}>
{shown}
{clipped && (
<button className="approval-prev-more" onClick={() => setAll((v) => !v)}>
{all
? "show less"
: lines.length > PREVIEW_LINES
? `show all ${lines.length} lines`
: "show the full message"}
</button>
)}
</div>
);
}
// Outbound message text: short one-liners keep the cozy inline quote; anything
// long (or multi-line) gets the clamped preview so the card stays card-sized.
function MessagePreview({ text, label }: { text: string; label?: string }) {
if (text.length <= 220 && !text.includes("\n")) {
return (
<div className="approval-with">
{label ? `${label}: ` : ""}{text}
</div>
);
}
return <PreviewBlock text={text} mono={false} />;
}
function Buttons({
item,
onApprove,
runTask,
primaryLabel,
}: {
item: ApprovalItem;
onApprove: (decision: ApprovalDecision) => void;
runTask?: { id: string; title: string } | null;
primaryLabel: string;
}) {
const connector = item.category === "connector";
const offerStanding = !!(runTask && item.standingTarget);
return (
<div className="approval-btns">
<button className="btn approval-primary" onClick={() => onApprove("once")}>
{primaryLabel}
</button>
{offerStanding && (
<button
className="btn"
title={`Always allow ${item.name}${item.standingTarget} for “${runTask?.title || "this automation"}” — revoke any time on its Automations page`}
onClick={() => onApprove("always_task")}
>
Allow every time
</button>
)}
{/* In a run context the task-persistent grant replaces the session-scoped one
a run session is ephemeral, and two adjacent "always" buttons would blur
exactly the scope distinction §25 exists to draw. Same rule for run_shell:
the command-scoped button below is the specific (safer) grant, so the
tool-wide one stays out of the card. */}
{!connector && !offerStanding && item.name !== "run_shell" && (
<button
className="btn"
title={`Always allow ${TOOL_VERBS[item.name]?.toLowerCase() || item.name} for this session`}
onClick={() => onApprove("always_tool")}
>
Always allow
</button>
)}
{item.name === "run_shell" && (
<button className="btn" onClick={() => onApprove("always_command")}>
Always allow this command
</button>
)}
<span className="spacer" />
<button className="btn quiet-deny" onClick={() => onApprove("deny")}>
Deny
</button>
</div>
);
}
export function ApprovalCard({
item,
onApprove,
runTask,
compact = false,
}: {
item: ApprovalItem;
onApprove: (decision: ApprovalDecision) => void;
// Present when this approval was raised inside an automation run — unlocks the
// task-persistent "Allow every time" (in-app only, §25).
runTask?: { id: string; title: string } | null;
compact?: boolean;
}) {
const [peek, setPeek] = useState(false);
const title = humanizeApprovalTitle(item.name, item.args);
const scope = scopeNote(item.name, item.args, item.category);
const grants = item.name === "create_scheduled_task" ? permissionLines(item.args) : [];
// "requires approval" is the engine's default boilerplate — only surface a real reason.
const reason = item.reason && item.reason !== "requires approval" ? item.reason : "";
const offerStanding = !!(runTask && item.standingTarget);
const dock = compact ? " approval-dock" : "";
// §35 compact row: routine workspace writes — one line, preview expands inline from the
// tool args. Standing/grant flows keep the full card (they carry §25 consent weight).
const content = typeof item.args?.content === "string" ? item.args.content : "";
if (FILE_WRITES.has(item.name) && !offerStanding && !grants.length && !item.resolved) {
return (
<div className={"approval approval-row" + dock} data-testid="approval-row">
<div className="approval-row-line">
<TitleText line={title} />
{content && (
<button className="approval-peek" onClick={() => setPeek((v) => !v)}>
preview {peek ? "▴" : "▾"}
</button>
)}
<span className="spacer" />
<Buttons item={item} onApprove={onApprove} runTask={runTask} primaryLabel="Allow" />
</div>
{peek && content && <PreviewBlock text={content} />}
{reason && <div className="approval-reason">{reason}</div>}
</div>
);
}
return (
<div className={"approval" + (scope.external ? " approval-external" : "") + dock}>
<div className="approval-top">
<div className="approval-heading">
<span className="approval-ico" title={`Tool: ${item.name}`}>
<Icon name="shield" size={15} />
</span>
<TitleText line={title} />
</div>
<span className={"approval-scope" + (scope.external ? " out" : "")}>{scope.text}</span>
</div>
{/* Tool-shaped previews — the proposal, not an args dump. */}
{item.name === "run_shell" && item.args?.command && (
<PreviewBlock text={String(item.args.command)} />
)}
{FILE_WRITES.has(item.name) && content && <PreviewBlock text={content} />}
{item.name === "send_file" && (
<>
<span className="approval-filechip">
<span className="ico">
<Icon name="file" size={13} />
</span>
{String(item.args?.path ?? "").split("/").pop() || "file"}
{item.args?.as_screenshot ? " · as a PNG screenshot" : ""}
</span>
{item.args?.comment && (
<MessagePreview text={String(item.args.comment)} label="With the message" />
)}
</>
)}
{item.name === "send_message" && item.args?.text && (
<MessagePreview text={String(item.args.text)} />
)}
{grants.length > 0 && (
<div className="approval-grants" data-testid="approval-grants">
{grants.map((g, i) => (
<div className="approval-grant" key={i} data-access={g.access}>
<span className={"grant-mark" + (g.access === "write" ? " write" : "")}>
{g.access === "write" ? "✓" : "·"}
</span>
<span className="grant-line">
{TOOL_VERBS[g.tool] || g.tool} <code className="approval-tool">{g.target}</code>
<span className="grant-note">
{g.access === "write" ? " — always allowed once you approve" : " — read-only"}
</span>
</span>
</div>
))}
</div>
)}
{/* Long-tail tools: no bespoke preview — fall back to the compact args line. */}
{!FILE_WRITES.has(item.name) &&
!["run_shell", "send_message", "send_file"].includes(item.name) &&
!grants.length &&
shortArgs(item.args) && <div className="approval-rest">{shortArgs(item.args)}</div>}
{reason && <div className="approval-reason">{reason}</div>}
{item.resolved ? (
<div className="resolved">Approved: {item.resolved.replace("_", " ")}</div>
) : (
<Buttons item={item} onApprove={onApprove} runTask={runTask} primaryLabel="Allow once" />
)}
</div>
);
}
+92
View File
@@ -0,0 +1,92 @@
import { useEffect, useState } from "react";
import { getAudit, type AuditEvent } from "../api";
import { PanelHead } from "./IntegrationsView";
// Activity — connector/browser tool history, restructured onto the IntegrationsView page shell
// (centered panel + PanelHead + cards), replacing the legacy `page-view` layout. Read-only:
// filterable, with sanitized arguments.
const CARD = "rounded-xl2 border border-line bg-panel";
const INPUT = "px-3 py-1.5 rounded-lg border border-line bg-paper text-[13px] text-ink outline-none focus:border-accent";
const BTN_ACCENT = "text-[12.5px] px-3 py-1.5 rounded-lg bg-accent text-white shrink-0";
export function AuditView() {
const [events, setEvents] = useState<AuditEvent[]>([]);
const [sessionFilter, setSessionFilter] = useState("");
const [connectorFilter, setConnectorFilter] = useState("");
const [toolFilter, setToolFilter] = useState("");
const refresh = () =>
getAudit({
limit: 150,
session_id: sessionFilter.trim() || undefined,
connector: connectorFilter.trim() || undefined,
tool: toolFilter.trim() || undefined,
})
.then(setEvents)
.catch(() => setEvents([]));
useEffect(() => {
refresh();
}, []);
return (
<main className="flex-1 min-w-0 flex bg-paper">
<div className="flex-1 min-w-0 overflow-y-auto hairline-scroll">
<div className="max-w-4xl mx-auto px-7 py-6">
<PanelHead
title="Activity"
sub="Recent connector and browser tool activity. Arguments are sanitized before storage."
/>
<div className="flex items-center gap-2 flex-wrap mb-4">
<input className={INPUT} placeholder="session id" value={sessionFilter} onChange={(e) => setSessionFilter(e.target.value)} />
<input className={INPUT} placeholder="connector" value={connectorFilter} onChange={(e) => setConnectorFilter(e.target.value)} />
<input className={INPUT} placeholder="tool" value={toolFilter} onChange={(e) => setToolFilter(e.target.value)} />
<button className={BTN_ACCENT} onClick={refresh}>
Filter
</button>
</div>
{events.length === 0 ? (
<div className={CARD + " p-4 text-[13px] text-muted"}>No audit events yet.</div>
) : (
<div className="space-y-2">
{events.map((ev) => (
<AuditRow ev={ev} key={ev.id} />
))}
</div>
)}
</div>
</div>
</main>
);
}
function AuditRow({ ev }: { ev: AuditEvent }) {
return (
<div className={CARD + " p-3.5"}>
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mono text-[12.5px] font-medium text-ink">{ev.tool}</span>
<span className="text-[11.5px] text-faint">
{ev.connector || "tool"} · {ev.stage || ev.status || "event"} · {ev.timestamp}
</span>
</div>
<div className="text-[11.5px] text-muted mt-0.5">
session {ev.session_id || "-"} {ev.approval ? `· ${ev.approval}` : ""} {ev.status ? `· ${ev.status}` : ""}
</div>
{ev.resource && <div className="text-[11.5px] text-faint mt-0.5">resource: {ev.resource}</div>}
{ev.args && Object.keys(ev.args).length > 0 && (
<div className="font-mono text-[11.5px] text-muted mt-1.5 break-words">{formatAuditArgs(ev.args)}</div>
)}
{(ev.reason || ev.result_preview) && (
<div className="text-[11.5px] text-faint mt-1">{ev.reason || ev.result_preview}</div>
)}
</div>
);
}
function formatAuditArgs(args: Record<string, any>) {
return Object.entries(args)
.map(([k, v]) => `${k}=${typeof v === "string" ? v : JSON.stringify(v)}`)
.join(" ");
}
@@ -0,0 +1,590 @@
import { useEffect, useRef, useState } from "react";
import {
cloudLogin,
connectManaged,
getCloudStatus,
getConnectors,
getRecentChannels,
waitForCloudSignIn,
type CloudStatus,
type Connector,
type RecentChannel,
} from "../api";
import { ConnectorBadge } from "../connectors/ConnectorIcon";
import { ChannelPicker } from "./SubscriptionsChip";
import { SelectMenu } from "./SelectMenu";
// The Automations quickstart (UX-DECISIONS §29): ONE template system. The former onboarding
// recipe step (§24's role recipes) merged into the page's "Start from a template" grid — every
// card carries §27's connector-dot vocabulary (brand = connected, grayscale = needs connecting);
// picking a card expands the configure card below the grid: connect rows (with the lazy cloud
// sign-in pane), channel-by-name, day × time, and the §25 consent line for write recipes.
// The `ob-*` testids moved here with the machinery.
// "When" = day choice × free time (owner call 2026-07-11); the cron assembles from the two.
const DAYS: Record<string, { label: string; dow: string }> = {
mon: { label: "Mondays", dow: "1" },
tue: { label: "Tuesdays", dow: "2" },
wed: { label: "Wednesdays", dow: "3" },
thu: { label: "Thursdays", dow: "4" },
fri: { label: "Fridays", dow: "5" },
sat: { label: "Saturdays", dow: "6" },
sun: { label: "Sundays", dow: "0" },
weekdays: { label: "Weekdays", dow: "1-5" },
daily: { label: "Every day", dow: "*" },
};
// §30 connect-state spinner (the app has no other spinner — waits elsewhere are label swaps).
// Exported for Onboarding page 2's sign-in button (same states, same look).
export const Spinner = () => (
<span className="inline-block w-3 h-3 rounded-full border-[1.5px] border-line2 border-t-accent animate-spin" />
);
const cronFor = (dayKey: string, hhmm: string) => {
const [h, m] = hhmm.split(":");
return `${Number(m) || 0} ${Number(h) || 9} * * ${DAYS[dayKey]?.dow ?? "*"}`;
};
interface QuickTemplate {
key: string;
title: string;
blurb: string;
cadence: string; // the card's footer label
conns: { name: string; why: string }[]; // [] = no connections needed
needsRepo?: boolean;
needsChannel?: boolean;
consent?: boolean; // write recipes carry the §25 consent line; reads carry disclosure
deliver?: boolean; // Morning brief's deliver-to choice
day: string;
time: string;
instructions: (ctx: { repo: string; channel: string; deliver: "app" | "slack" }) => string;
}
const TEMPLATES: QuickTemplate[] = [
{
key: "github",
title: "GitHub digest",
blurb: "Merged PRs and commits, posted to your team's Slack.",
cadence: "Weekly",
conns: [
{ name: "slack", why: "Where the digest posts" },
{ name: "github", why: "What the digest summarizes" },
],
needsRepo: true,
needsChannel: true,
consent: true,
day: "mon",
time: "09:00",
instructions: ({ repo, channel }) =>
`Summarize activity since the last digest in the GitHub repository ${repo || "(the connected repository)"}: ` +
`merged pull requests, notable commits, and anything needing attention. ` +
`Post the digest to the Slack channel ${channel} using send_message.`,
},
{
key: "pipeline",
title: "Pipeline digest",
blurb: "Deals that moved — and deals going quiet — posted to Slack.",
cadence: "Weekly",
conns: [
{ name: "slack", why: "Where the digest posts" },
{ name: "hubspot", why: "Pipeline and deal activity" },
],
needsChannel: true,
consent: true,
day: "mon",
time: "09:00",
instructions: ({ channel }) =>
`Review HubSpot activity since the last digest: deals that changed stage, deals going ` +
`quiet, and deals past their close date. Post a short pipeline digest to the Slack ` +
`channel ${channel} using send_message.`,
},
{
key: "brief",
title: "Morning brief",
blurb: "Calendar and unread email, summarized before your day starts.",
cadence: "Daily",
conns: [
{ name: "google_calendar", why: "Today's meetings and gaps" },
{ name: "gmail", why: "What arrived overnight" },
],
deliver: true,
day: "daily",
time: "08:00",
instructions: ({ deliver }) =>
`Prepare a short morning brief: today's calendar events and gaps, plus email that ` +
`arrived since yesterday evening. ` +
(deliver === "app" ? "Save it as the session deliverable." : "Send it to me as a Slack DM."),
},
{
key: "news",
title: "Morning news briefing",
blurb: "A 5-bullet tech & world news digest, saved as markdown.",
cadence: "Daily",
conns: [],
day: "daily",
time: "08:00",
instructions: () =>
"Search the web for the most important technology and world news from the last 24 hours " +
"and write a concise 5-bullet briefing, saved as a markdown file.",
},
{
key: "inboxdigest",
title: "Inbox digest",
blurb: "One short digest of your unread email.",
cadence: "Weekdays",
conns: [{ name: "gmail", why: "Your unread email" }],
day: "weekdays",
time: "09:00",
instructions: () => "Summarize my unread email into one short digest note.",
},
{
key: "cleanup",
title: "Folder cleanup",
blurb: "Sort recent Downloads into tidy folders by type.",
cadence: "Weekly",
conns: [],
day: "fri",
time: "17:30",
instructions: () => "Sort my recent Downloads into tidy folders by file type.",
},
];
export function AutomationQuickstart({
busy,
onCreate,
}: {
busy: boolean;
onCreate: (payload: {
title: string;
instructions: string;
cron?: string;
permissions?: { tool: string; target: string; access: "read" | "write" }[];
}) => void;
}) {
const [pickedKey, setPickedKey] = useState<string | null>(null);
const picked = TEMPLATES.find((t) => t.key === pickedKey) || null;
const [connectors, setConnectors] = useState<Connector[]>([]);
const [cloud, setCloud] = useState<CloudStatus | null>(null);
const [pendingConn, setPendingConn] = useState<string | null>(null);
// §30 connect states: "opening" while the broker POST is in flight (the browser hasn't
// appeared yet), "waiting" once it has — the handoff strip explains the out-of-band finish.
const [connFlow, setConnFlow] = useState<{ name: string; phase: "opening" | "waiting" } | null>(
null,
);
const [signinPhase, setSigninPhase] = useState<"opening" | "waiting" | null>(null);
const [recent, setRecent] = useState<RecentChannel[]>([]);
const [repo, setRepo] = useState("");
const [channel, setChannel] = useState("");
const [day, setDay] = useState("mon");
const [time, setTime] = useState("09:00");
const [deliver, setDeliver] = useState<"app" | "slack">("app");
const [consent, setConsent] = useState(true);
const refresh = () => {
getConnectors().then(setConnectors).catch(() => {});
getCloudStatus().then(setCloud).catch(() => {});
};
// Connector state drives the card dots, so load once up front; poll only while a template
// is being configured (connects and the cloud sign-in land out-of-band).
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
refresh();
}, []);
useEffect(() => {
if (!picked) return;
refresh();
getRecentChannels().then(setRecent).catch(() => {});
pollRef.current = setInterval(refresh, 3000);
return () => {
if (pollRef.current) clearInterval(pollRef.current);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pickedKey]);
const connState = (name: string) => connectors.find((c) => c.name === name);
const allConnected = !picked || picked.conns.every((c) => connState(c.name)?.connected);
// §25 consent line shows the HUMAN name (owner catch 2026-07-14: it echoed the raw
// slack:T…/C… target). Names come from a picker pick (remembered per address) or the
// recent list; a hand-typed raw address stays raw — we never guess.
const [picked_names, setPickedNames] = useState<Record<string, { name: string; workspace?: string }>>({});
const pickedInfo = picked_names[channel];
const channelName = pickedInfo?.name || recent.find((c) => c.channel === channel)?.name;
const channelLabel = channelName ? `#${channelName}` : channel;
const channelWorkspace = pickedInfo?.workspace;
// The poll flipping a row to ✓ is what ends its waiting state.
useEffect(() => {
if (connFlow && connState(connFlow.name)?.connected) setConnFlow(null);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [connectors]);
// §30: the configure card scrolls into view on pick — it expands below the fold on
// three-row grids and otherwise appears "nowhere".
const cfgRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (pickedKey) cfgRef.current?.scrollIntoView({ behavior: "smooth", block: "nearest" });
}, [pickedKey]);
const pick = (t: QuickTemplate) => {
setPickedKey(t.key);
setDay(t.day);
setTime(t.time);
setConsent(true);
setConnFlow(null);
};
const startConnect = async (name: string) => {
if (!cloud?.signed_in) {
setPendingConn(name); // the pane appears; sign-in completes it
return;
}
// §30: the broker round-trip takes seconds — narrate it on the row itself.
setConnFlow({ name, phase: "opening" });
// GitHub is authorize-first at the BROKER: one connect links an existing
// installation or lands on the install page — no flow choice here anymore.
await connectManaged(name).catch(() => {});
// The POST resolves once the system browser is off; the poll ends the waiting state.
setConnFlow((f) => (f?.name === name ? { name, phase: "waiting" } : f));
refresh();
};
const signinPollRef = useRef<(() => void) | null>(null);
const cancelSignin = () => {
signinPollRef.current?.();
signinPollRef.current = null;
setSigninPhase(null);
};
useEffect(() => cancelSignin, []); // never leave the poll running after unmount
const signInThenConnect = async () => {
setSigninPhase("opening");
await cloudLogin().catch(() => {});
setSigninPhase("waiting");
// Poll until the browser flow lands, then finish the pending connect (bounded).
signinPollRef.current = waitForCloudSignIn(async (s) => {
signinPollRef.current = null;
setSigninPhase(null);
if (!s?.signed_in) return;
setCloud(s);
if (pendingConn) {
const name = pendingConn;
setConnFlow({ name, phase: "opening" });
await connectManaged(name).catch(() => {});
setConnFlow((f) => (f?.name === name ? { name, phase: "waiting" } : f));
setPendingConn(null);
refresh();
}
});
};
const create = () => {
if (!picked) return;
onCreate({
title: picked.title,
instructions: picked.instructions({ repo, channel, deliver }),
cron: cronFor(day, time),
permissions:
picked.consent && consent && channel
? [{ tool: "send_message", target: channel, access: "write" }]
: [],
});
};
const gateHint = !allConnected
? `Connect ${picked?.conns
.filter((c) => !connState(c.name)?.connected)
.map((c) => connState(c.name)?.title || c.name)
.join(" and ")} to continue`
: picked?.needsChannel && !channel
? "Pick a channel to post to first"
: "";
const label = "block text-[12px] text-muted mt-3 mb-1";
const input =
"w-full px-3 py-2 rounded-lg border border-line bg-panel text-[13.5px] outline-none focus:border-accent";
return (
<div className="mb-4">
<div className="text-[11px] uppercase tracking-[0.05em] text-faint mb-2.5">
Start from a template
</div>
{/* Equal-height cards (owner ask 2026-07-12): 1fr rows + h-full <button> grid items
don't stretch like divs. */}
<div className="grid grid-cols-3 auto-rows-fr gap-3">
{TEMPLATES.map((t) => (
<button
key={t.key}
data-testid={`qs-template-${t.key}`}
className={
"h-full text-left rounded-xl2 border bg-panel p-4 flex flex-col gap-1.5 " +
(pickedKey === t.key
? "border-accent ring-2 ring-accentSoft"
: "border-line hover:border-lineStrong")
}
onClick={() => pick(t)}
>
<span className="text-[13.5px] font-semibold">{t.title}</span>
<span className="text-[12px] text-muted leading-relaxed flex-1">{t.blurb}</span>
<span className="flex items-center gap-1.5 mt-1">
{t.conns.map((c) => {
const cs = connState(c.name);
const on = !!cs?.connected;
return (
<span
key={c.name}
title={`${cs?.title || c.name}${on ? "connected" : "not connected yet"}`}
style={on ? undefined : { filter: "grayscale(1)", opacity: 0.55 }}
>
{cs ? (
<ConnectorBadge connector={cs} size={16} title={cs.title} />
) : (
<span className="inline-block w-4 h-4 rounded-full border border-line2" />
)}
</span>
);
})}
<span className="text-[11px] text-faint ml-0.5">
{t.conns.length === 0 ? `No connections needed · ${t.cadence}` : t.cadence}
</span>
</span>
</button>
))}
</div>
{picked && (
<div
ref={cfgRef}
className="mt-3 rounded-xl2 border border-line bg-panel p-4"
data-testid="qs-configure"
>
{/* §30: the card names its template — without this it starts abruptly after the grid. */}
<div className="flex items-baseline gap-2 pb-2.5 mb-1 border-b border-line">
<span className="text-[11px] uppercase tracking-[0.05em] text-accent font-semibold">
Set up
</span>
<span className="text-[14px] font-semibold">{picked.title}</span>
<span className="ml-auto text-[12px] text-faint max-sm:hidden">
{picked.conns.length ? "Connections, delivery & schedule" : "Delivery & schedule"} ·{" "}
{picked.cadence}
</span>
</div>
{picked.conns.map(({ name, why }) => {
const c = connState(name);
const flow = connFlow?.name === name ? connFlow : null;
return (
<div key={name} className="border-b border-line last:border-b-0">
<div className="flex items-center gap-3 py-2.5">
{c && <ConnectorBadge connector={c} size={26} title={c.title} />}
<span className="min-w-0 flex-1">
<span className="block text-[13.5px] font-medium">{c?.title || name}</span>
<span className="block text-[11.5px] text-faint">{why}</span>
</span>
{c?.connected ? (
<span className="text-[12.5px] text-ok"> Connected</span>
) : flow ? (
<span className="inline-flex items-center gap-2 text-[12px] text-muted">
<Spinner />
{flow.phase === "opening"
? "Opening browser…"
: `Waiting for ${c?.title || name}`}
</span>
) : (
<button
className="px-3.5 py-1 rounded-full border border-line text-[12.5px] hover:bg-paper"
onClick={() => startConnect(name)}
data-testid={`ob-connect-${name}`}
>
Connect
</button>
)}
</div>
{/* §30 handoff strip: the flow finishes out-of-band in the browser say so,
and let Cancel clear the LOCAL state (the browser tab is the user's). */}
{flow?.phase === "waiting" && (
<div
className="flex items-start gap-2 bg-accentSoft/50 rounded-lg px-3 py-2 mb-2.5 text-[12px] text-muted"
data-testid="ob-connect-wait"
>
<span></span>
<span className="flex-1 min-w-0">
<b className="text-ink font-medium">
Finish connecting {c?.title || name} in your browser.
</b>{" "}
Approve it there, then come back this page updates by itself.
</span>
<button
className="text-faint underline hover:text-muted shrink-0"
onClick={() => setConnFlow(null)}
data-testid="ob-connect-cancel"
>
Cancel
</button>
</div>
)}
</div>
);
})}
{pendingConn && !cloud?.signed_in && (
<div
className="bg-accentSoft/50 rounded-xl px-4 py-3 mt-3 text-[12.5px] text-muted"
data-testid="ob-cloudpane"
>
<span className="block text-[13px] text-ink font-medium">
One sign-in unlocks every one-click connection
</span>
Connections are brokered by OpenWorker Cloud your tokens stay on this Mac.
<div className="flex items-center gap-3 mt-2">
{signinPhase ? (
<>
<span className="inline-flex items-center gap-2 text-[12px]">
<Spinner />
{signinPhase === "opening" ? "Opening browser…" : "Waiting for sign-in…"}
</span>
{signinPhase === "waiting" && (
<span className="text-[11.5px] text-faint">
Finish signing in in your browser this page updates by itself.{" "}
<button
className="underline hover:text-muted"
onClick={cancelSignin}
data-testid="ob-signin-cancel"
>
Cancel
</button>
</span>
)}
</>
) : (
<button
className="px-3.5 py-1 rounded-full border border-line text-[12.5px] text-accent hover:bg-panel"
onClick={signInThenConnect}
data-testid="ob-cloud-signin"
>
Sign in to OpenWorker Cloud
</button>
)}
</div>
</div>
)}
{allConnected && (
<div className={picked.conns.length ? "bg-paper rounded-xl px-4 py-3.5 mt-3" : ""} data-testid="ob-recipe">
{picked.needsRepo && (
<>
<label className={label}>Repository</label>
<input
className={input}
placeholder="owner/repo"
value={repo}
onChange={(e) => setRepo(e.target.value)}
data-testid="ob-repo"
/>
</>
)}
{picked.needsChannel && (
<>
<label className={label}>Post to channel</label>
<div data-testid="ob-channel">
<ChannelPicker
value={channel}
onChange={setChannel}
recent={recent}
onPickName={(address, name, workspace) =>
setPickedNames((m) => ({ ...m, [address]: { name, workspace } }))
}
/>
</div>
<p className="text-[11px] text-warnInk mt-1">
The bot must be a member of the channel invite @ocw in Slack if it isn't.
</p>
</>
)}
<label className={label}>When</label>
<div className="flex gap-2">
<div className="flex-1 min-w-0">
<SelectMenu
ariaLabel="Day"
value={day}
options={Object.entries(DAYS).map(([k, v]) => ({ value: k, label: v.label }))}
onChange={setDay}
/>
</div>
<input
className="w-28 px-3 py-2 rounded-lg border border-line bg-panel text-[13.5px] outline-none focus:border-accent"
type="time"
aria-label="Time"
value={time}
onChange={(e) => setTime(e.target.value)}
/>
</div>
{picked.deliver && (
<>
<label className={label}>Deliver to</label>
<SelectMenu
ariaLabel="Deliver to"
value={deliver}
options={[
{ value: "app", label: "In the app" },
{ value: "slack", label: "Slack DM (connect Slack later)" },
]}
onChange={(v) => setDeliver(v as "app" | "slack")}
/>
</>
)}
{picked.consent ? (
<label className="flex items-start gap-2.5 mt-3.5 text-[12.5px] text-muted select-none">
<input
type="checkbox"
className="mt-0.5"
checked={consent}
onChange={(e) => setConsent(e.target.checked)}
data-testid="ob-consent"
/>
<span>
Allow this automation to post its digest to{" "}
<b className="text-ink" title={channel || undefined}>
{channelLabel || "the channel"}
{channelWorkspace ? ` (${channelWorkspace})` : ""}
</b>{" "}
without asking each time. Anything else still asks first.
</span>
</label>
) : picked.conns.length > 0 ? (
<p className="text-[12.5px] text-muted mt-3">
This automation only <b className="text-ink">reads</b> on schedule reading
never needs approval.
</p>
) : null}
</div>
)}
<div className="flex items-center gap-3 mt-4">
<button
className="text-[12.5px] text-faint hover:text-muted"
onClick={() => setPickedKey(null)}
>
Cancel
</button>
{/* A silently-disabled primary reads as a bug — always name the missing piece. */}
{gateHint && (
<span className="ml-auto text-[11.5px] text-faint" data-testid="ob-create-hint">
{gateHint}
</span>
)}
<button
className={
(gateHint ? "" : "ml-auto ") +
"px-5 py-2 rounded-full bg-ink text-panel text-[13px] disabled:opacity-40"
}
disabled={busy || !allConnected || (picked.needsChannel && !channel)}
onClick={create}
data-testid="ob-create"
>
{busy ? "Creating…" : "Create automation"}
</button>
</div>
</div>
)}
</div>
);
}
+654
View File
@@ -0,0 +1,654 @@
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react";
import type { Attachment } from "../types";
import { isPdfFile, readFile } from "../attach";
import { getSettings, inspectPdf } from "../api";
import { Dropdown, type Option } from "./Dropdown";
import { Icon } from "./Icon";
import { Toggle } from "./Toggle";
import {
cancelDictation,
getDictationLevel,
getDictationStatus,
isTauri,
startDictation,
stopDictation,
type DictationStatus,
} from "../tauri";
const PERMISSION_OPTIONS: Option[] = [
{ value: "discuss", label: "Discuss", description: "Chat and explore — no edits or commands" },
{ value: "plan", label: "Plan", description: "Explore read-only, propose a plan for approval, then build" },
{ value: "interactive", label: "Ask for approval", description: "Ask before edits and commands" },
{ value: "auto", label: "Full access", description: "Run everything without asking" },
{ value: "custom", label: "Custom", description: "Use auto-allow rules from config.toml" },
];
// Fallback list when the server hasn't supplied one yet; the live list (incl. detected Ollama
// models) arrives via the `models` prop.
const MODEL_VALUES = ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.5"];
// Drop the provider prefix for display (anthropic:claude-opus-4-8 → claude-opus-4-8); full id on hover.
const shortModel = (m: string) => (m.includes(":") ? m.split(":").slice(1).join(":") : m);
// Identify an attachment by name + payload size so duplicates (e.g. the same file picked twice,
// or a prefill applied twice) collapse to one chip.
const attKey = (a: Attachment) =>
a.kind === "text"
? `t:${a.name}:${a.text?.length ?? 0}`
: `${a.kind[0]}:${a.name}:${a.data_url?.length ?? 0}`;
const mergeAttachments = (cur: Attachment[], add: Attachment[]): Attachment[] => {
const seen = new Set(cur.map(attKey));
return [...cur, ...add.filter((a) => !seen.has(attKey(a)))].slice(0, 8);
};
interface Props {
mode: string;
model: string;
models?: string[];
modelLabels?: Record<string, string>; // curated display names (raw id when absent)
// The model is FIXED once the session has history (§17): the picker renders ONLY on a fresh
// session; after the first turn the fact lives in the topbar subtitle (§22) — no
// interactive-then-disabled control.
modelLocked?: boolean;
running: boolean;
connected: boolean;
// False when the default model's provider has no key — the composer shows a "connect a model"
// banner and routes sends to setup (preserving the draft) instead of dropping them.
modelReady?: boolean;
onConnectModel?: () => void;
onConfigureVoiceInput?: () => void;
onSend: (text: string, attachments?: Attachment[]) => void;
onInterrupt: () => void;
onModeChange: (mode: string) => void;
onModelChange: (model: string) => void;
// When set (Code/Cowork), the Mode menu is shown. The folder/roots + branch controls left the
// composer for the Session settings drawer (§22) — folder access is standing session config.
workspace?: string;
// Unattended / send-approvals-to-Inbox — folded into the Mode menu (§22): "who approves, and
// when" is one mental model. Absent handler = no toggle (e.g. Chat).
unattended?: boolean;
onUnattendedChange?: (on: boolean) => void;
approvalSlot?: ReactNode;
// Push text + attachments into the composer (e.g. a start-panel task card). The `nonce` makes
// repeated identical prefills re-apply; the user can still edit before sending.
prefill?: { text: string; attachments?: Attachment[]; nonce: number };
// Changes when the active conversation changes; clears any unsent draft.
resetKey?: string;
// Surface-specific hint shown in the empty textarea.
placeholder?: string;
}
export function Composer(props: Props) {
const [text, setText] = useState("");
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [dragging, setDragging] = useState(false);
const [attachMenuOpen, setAttachMenuOpen] = useState(false);
const [dictation, setDictation] = useState<DictationStatus | null>(null);
const [dictationBusy, setDictationBusy] = useState<string | null>(null);
const [dictationError, setDictationError] = useState<string | null>(null);
const [recordingSeconds, setRecordingSeconds] = useState(0);
const [attachNotice, setAttachNotice] = useState<string | null>(null);
const fileInput = useRef<HTMLInputElement | null>(null);
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const noticeTimer = useRef<number | null>(null);
// Rejected-attachment notice: visible ~8s, then clears (or on ✕).
const showAttachNotice = (message: string) => {
setAttachNotice(message);
if (noticeTimer.current) window.clearTimeout(noticeTimer.current);
noticeTimer.current = window.setTimeout(() => setAttachNotice(null), 8000);
};
useLayoutEffect(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = "auto";
const max = parseFloat(getComputedStyle(el).lineHeight || "22") * 4;
const next = Math.min(el.scrollHeight, max);
el.style.height = `${Math.max(next, 24)}px`;
el.style.overflowY = el.scrollHeight > max ? "auto" : "hidden";
}, [text]);
// Apply a prefill (text + attachments) pushed from outside, then focus the composer. Applied at
// most once per nonce (a ref guards against StrictMode/re-render double-fires), and attachments
// are de-duplicated so the same file never lands twice.
const appliedNonce = useRef<number>(-1);
useEffect(() => {
const p = props.prefill;
if (!p || p.nonce === appliedNonce.current) return;
appliedNonce.current = p.nonce;
setText(p.text);
if (p.attachments?.length) setAttachments((cur) => mergeAttachments(cur, p.attachments!));
textareaRef.current?.focus();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.prefill?.nonce]);
// Clear the draft when the conversation changes, so a half-typed message / picked file doesn't
// bleed from one session into another.
useEffect(() => {
setText("");
setAttachments([]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.resetKey]);
// Dictation is intentionally native-only: the browser/dev build remains a local server client
// and never turns on the browser microphone or ships audio anywhere.
useEffect(() => {
if (!isTauri()) return;
const refresh = (event?: Event) => {
const supplied = (event as CustomEvent<DictationStatus> | undefined)?.detail;
if (supplied) {
setDictation(supplied);
return;
}
void getDictationStatus().then((status) => status && setDictation(status));
};
refresh();
window.addEventListener("coworker:voice-input-changed", refresh);
return () => window.removeEventListener("coworker:voice-input-changed", refresh);
}, []);
useEffect(() => {
if (!dictation?.recording) {
setRecordingSeconds(0);
return;
}
const started = Date.now();
const timer = window.setInterval(() => {
setRecordingSeconds(Math.floor((Date.now() - started) / 1000));
}, 250);
return () => window.clearInterval(timer);
}, [dictation?.recording]);
// Live waveform: poll mic loudness at ~10Hz while recording; the bars scroll left so the
// trace reads as a real input meter (owner catch on DMG #28 — the first cut's bars were
// decorative constants and read as fake).
const [levels, setLevels] = useState<number[]>([]);
useEffect(() => {
if (!dictation?.recording) {
setLevels([]);
return;
}
const timer = window.setInterval(() => {
getDictationLevel().then((level) => {
if (typeof level === "number") setLevels((cur) => [...cur.slice(-13), level]);
});
}, 100);
return () => window.clearInterval(timer);
}, [dictation?.recording]);
useEffect(() => {
if (!dictation?.recording) return;
const cancelOnEscape = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
event.preventDefault();
void cancelDictation()
.catch(() => undefined)
.finally(() => {
void getDictationStatus().then((status) => status && setDictation(status));
});
};
window.addEventListener("keydown", cancelOnEscape);
return () => window.removeEventListener("keydown", cancelOnEscape);
}, [dictation?.recording]);
const voiceReady = !!dictation?.supported && !!dictation?.model_verified && !!dictation?.test_passed;
const recordingTime = `${Math.floor(recordingSeconds / 60)}:${String(recordingSeconds % 60).padStart(2, "0")}`;
// Attach-time PDF thresholds (Settings → Token savings): a PDF over the user's page or
// size limit is REJECTED with a visible notice — never attached, never silently dropped.
// The rationale is token cost: a big PDF re-rides every turn of the conversation.
const addFiles = async (files: FileList | File[]) => {
const list = Array.from(files);
let maxPages = 20;
let maxMb = 10;
if (list.some(isPdfFile)) {
try {
const s = await getSettings();
if (s.pdf_max_pages) maxPages = s.pdf_max_pages;
if (s.pdf_max_mb) maxMb = s.pdf_max_mb;
} catch {
/* offline settings fetch — fall back to defaults */
}
}
const accepted: File[] = [];
for (const file of list) {
if (isPdfFile(file) && file.size > maxMb * 1024 * 1024) {
showAttachNotice(
`${file.name} skipped — ${(file.size / 1024 / 1024).toFixed(1)} MB is over your ${maxMb} MB limit (Settings → Token savings)`,
);
continue;
}
accepted.push(file);
}
const read = (await Promise.all(accepted.map(readFile))).filter(Boolean) as Attachment[];
const next: Attachment[] = [];
for (const a of read) {
if (a.kind === "pdf" && a.data_url) {
const info = await inspectPdf(a.data_url).catch(() => null);
if (info?.ok && (info.pages ?? 0) > maxPages) {
showAttachNotice(
`${a.name} skipped — ${info.pages} pages is over your ${maxPages}-page limit (Settings → Token savings)`,
);
continue;
}
if (info && !info.ok) {
showAttachNotice(`${a.name} skipped — ${info.error || "could not read PDF"}`);
continue;
}
}
next.push(a);
}
if (next.length) setAttachments((a) => mergeAttachments(a, next));
};
// The "+" menu offers typed shortcuts; each just narrows the OS picker's filter.
const pickFiles = (accept: string) => {
setAttachMenuOpen(false);
if (fileInput.current) {
fileInput.current.accept = accept;
fileInput.current.click();
}
};
const needsModel = props.modelReady === false;
const submit = () => {
const t = text.trim();
if ((!t && attachments.length === 0) || props.running || dictation?.recording || dictationBusy) return;
// No model connected: keep the draft (don't drop it) and send the user to setup instead.
if (needsModel) {
props.onConnectModel?.();
return;
}
props.onSend(t, attachments);
setText("");
setAttachments([]);
};
const onKey = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
submit();
}
};
const onPaste = (e: React.ClipboardEvent) => {
const imgs = Array.from(e.clipboardData.items)
.filter((it) => it.kind === "file" && it.type.startsWith("image/"))
.map((it) => it.getAsFile())
.filter(Boolean) as File[];
if (imgs.length) {
e.preventDefault();
addFiles(imgs);
}
};
const toggleDictation = async () => {
if (!isTauri() || dictationBusy) return;
setDictationError(null);
try {
if (dictation?.recording) {
setDictationBusy("Transcribing…");
const transcript = await stopDictation();
if (transcript === null) throw new Error("Could not transcribe your recording.");
if (transcript.trim()) {
setText((draft) => (draft.trim() ? `${draft.trimEnd()} ${transcript.trim()}` : transcript.trim()));
}
setDictation(await getDictationStatus());
textareaRef.current?.focus();
return;
}
const status = dictation || (await getDictationStatus());
if (!status) throw new Error("Voice dictation is unavailable.");
if (!status.supported || !status.model_verified || !status.test_passed) {
props.onConfigureVoiceInput?.();
return;
}
setDictationBusy("Starting microphone…");
const recording = await startDictation();
if (!recording?.recording) throw new Error("Could not start the microphone.");
setDictation(recording);
} catch (error) {
setDictationError(error instanceof Error ? error.message : "Voice dictation is unavailable.");
const status = await getDictationStatus();
if (status) setDictation(status);
} finally {
setDictationBusy(null);
}
};
const available = props.models && props.models.length ? props.models : MODEL_VALUES;
const modelOptions: Option[] = Array.from(new Set([props.model, ...available])).map((m) => ({
value: m,
label: props.modelLabels?.[m] || shortModel(m),
}));
const iconBtn =
"w-7 h-7 grid place-items-center rounded-md text-muted hover:text-ink hover:bg-paper shrink-0";
// The send button is accent only when there's something to send — subtle grey otherwise, so the
// composer isn't carrying a constant blue dot.
const hasContent = text.trim().length > 0 || attachments.length > 0;
return (
<div className="composer-wrap px-6 pb-5 pt-4">
{props.approvalSlot}
{dictationError && (
<div className="max-w-3xl mx-auto mb-2 px-1 text-[12px] text-red-600" role="alert">
{dictationError}
</div>
)}
{/* Rejected-attachment notice (PDF over the user's Token-savings thresholds). */}
{attachNotice && (
<div
data-testid="attach-notice"
className="max-w-3xl mx-auto mb-1.5 flex items-center gap-2 rounded-lg border border-warnInk/30 bg-warnSoft px-3 py-1.5 text-[12.5px] text-warnInk"
>
<span className="flex-1">{attachNotice}</span>
<button
className="shrink-0 opacity-60 hover:opacity-100"
onClick={() => setAttachNotice(null)}
title="Dismiss"
>
</button>
</div>
)}
{/* Attachments preview — a strip ABOVE the input box (mock/Claude-style). */}
{attachments.length > 0 && (
<div className="max-w-3xl mx-auto mb-1.5 flex flex-wrap gap-2">
{attachments.map((a, i) => (
<AttachChip key={i} a={a} onRemove={() => setAttachments((all) => all.filter((_, j) => j !== i))} />
))}
</div>
)}
<div
className={
"composer max-w-3xl mx-auto rounded-2xl border border-line bg-panel shadow-sm" +
(dragging ? " dragging" : "")
}
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
if (e.dataTransfer.files.length) addFiles(e.dataTransfer.files);
}}
>
<textarea
ref={textareaRef}
className="w-full block px-3.5 pt-3.5 pb-1.5 text-[14.5px]"
placeholder={props.placeholder || "Ask the coworker… (drop or paste files)"}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={onKey}
onPaste={onPaste}
rows={1}
/>
{/* Three-control row (§22): + attach · Mode ⌄ …(right)… model (fresh only) · send */}
<div className="px-2.5 pb-2.5 pt-1 flex items-center gap-1.5">
{/* + attach menu */}
<div className="relative">
<button
className={iconBtn + (attachMenuOpen ? " bg-paper text-ink" : "")}
title="Attach"
aria-label="Attach"
onClick={() => setAttachMenuOpen((v) => !v)}
>
<Icon name="plus" size={17} />
</button>
{attachMenuOpen && (
<>
<div className="fixed inset-0 z-30" onClick={() => setAttachMenuOpen(false)} />
<div className="absolute z-40 bottom-full mb-1 left-0 min-w-[180px] rounded-xl border border-line bg-panel shadow-2xl py-1.5">
{attachItem("image", "Photo or image", () => pickFiles("image/*"))}
{attachItem("file", "PDF", () => pickFiles("application/pdf,.pdf"))}
{attachItem(
"fileCode",
"Other files",
() => pickFiles("text/*,.md,.csv,.json,.yaml,.yml,.log,.py,.ts,.tsx,.js,.rs,.go,.toml"),
)}
</div>
</>
)}
</div>
<input
ref={fileInput}
type="file"
multiple
style={{ display: "none" }}
onChange={(e) => {
if (e.target.files) addFiles(e.target.files);
e.target.value = "";
}}
/>
{/* Listening replaces the quiet middle controls with a LIVE waveform (mic RMS,
polled ~10Hz, scrolling left) + elapsed time (§37). */}
{dictation?.recording ? (
<div className="voice-wave-row flex-1 flex items-center gap-2 ml-1" aria-hidden="true">
<span className="voice-wave-line" />
<span className="voice-wave-bars">
{Array.from({ length: 14 }, (_, index) => {
const level = levels[levels.length - 14 + index] ?? 0;
return <i key={index} style={{ height: Math.round(4 + level * 24) }} />;
})}
</span>
<span className="text-[12px] text-muted tabular-nums">{recordingTime}</span>
</div>
) : props.workspace !== undefined ? (
<ModeMenu
mode={props.mode}
onModeChange={props.onModeChange}
unattended={props.unattended}
onUnattendedChange={props.onUnattendedChange}
/>
) : null}
{dictationBusy === "Transcribing…" && <span className="text-[11.5px] text-accent">Transcribing</span>}
<span className="ml-auto" />
{/* model a quiet chip on a FRESH session only; once the session has history the
fact moves up to the topbar subtitle (§17 expressed spatially). */}
{!dictation?.recording && (needsModel ? (
<button
className="pill model-warn chip"
onClick={() => props.onConnectModel?.()}
title="Connect a model"
aria-label="No model connected — connect a model"
>
<span className="pill-label">No model</span>
<span className="model-warn-ico" aria-hidden></span>
</button>
) : (
!props.modelLocked && (
<Dropdown value={props.model} options={modelOptions} onChange={props.onModelChange} align="right" />
)
))}
{/* mic — immediately before send (owner call, DMG #28 walkthrough) */}
{isTauri() && (
<button
className={
iconBtn +
(dictation?.recording ? " bg-red-50 text-red-600 hover:bg-red-100" : "") +
(dictationBusy ? " opacity-60" : "") +
(!voiceReady && !dictation?.recording ? " opacity-40" : "")
}
onClick={() => void toggleDictation()}
disabled={!!dictationBusy}
title={
dictationBusy ||
(dictation?.recording
? "Stop recording and transcribe"
: voiceReady
? "Start local voice dictation"
: "Configure Voice Input in Settings")
}
aria-label={dictation?.recording ? "Stop dictation" : voiceReady ? "Start dictation" : "Configure Voice Input in Settings"}
aria-disabled={!voiceReady && !dictation?.recording}
>
<Icon name={dictation?.recording ? "stop" : "mic"} size={16} />
</button>
)}
{/* send / stop */}
{props.running ? (
<button className="btn danger" onClick={props.onInterrupt}>
Stop
</button>
) : (
<button
className={
"w-7 h-7 rounded-full grid place-items-center shrink-0 transition-colors " +
(hasContent && props.connected && !dictation?.recording && !dictationBusy
? "bg-accent text-white hover:brightness-105"
: "bg-paper border border-line text-faint")
}
onClick={submit}
disabled={!props.connected || !!dictation?.recording || !!dictationBusy}
title={needsModel ? "Connect a model to send" : undefined}
aria-label="Send"
>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M12 19V5M5 12l7-7 7 7" />
</svg>
</button>
)}
</div>
</div>
<span className="sr-only" role="status" aria-live="polite">
{dictation?.recording ? `Listening, ${recordingTime}` : dictationBusy || ""}
</span>
</div>
);
}
// The composer's Mode menu (§22): a quiet "Mode ⌄" chip opening the five permission options with
// the current one marked, plus — when the session supports it — the "Send approvals to Inbox"
// toggle at the bottom (the old standalone InboxControl, folded in).
function ModeMenu({
mode,
onModeChange,
unattended,
onUnattendedChange,
}: {
mode: string;
onModeChange: (mode: string) => void;
unattended?: boolean;
onUnattendedChange?: (on: boolean) => void;
}) {
const [open, setOpen] = useState(false);
const current = PERMISSION_OPTIONS.find((o) => o.value === mode);
return (
<div className="relative">
{/* Borderless, and it names the CHOSEN mode (owner ask 2026-07-11, competitor composer
comparison): "Ask for approval ⌄" not a generic "Mode ⌄" pill. aria-label stays
"Mode" so the accessible name is stable across mode changes. */}
<button
className="inline-flex items-center gap-1 px-2 py-1 rounded-lg text-[12px] text-muted hover:text-ink hover:bg-paper shrink-0"
onClick={() => setOpen((v) => !v)}
aria-haspopup="menu"
aria-expanded={open}
aria-label="Mode"
title={
`Mode: ${current?.label || mode}` +
(unattended ? " · approvals go to the Inbox" : "")
}
>
{current?.label || mode}
<Icon name="chevronDown" size={11} className="text-faint" />
</button>
{open && (
<>
<div className="fixed inset-0 z-30" onClick={() => setOpen(false)} />
<div
className="absolute z-40 bottom-full mb-1 left-0 w-[260px] rounded-xl border border-line bg-panel shadow-2xl p-1.5"
role="menu"
data-testid="mode-menu"
>
{PERMISSION_OPTIONS.map((o) => (
<button
key={o.value}
className="w-full flex flex-col items-start px-2.5 py-1.5 rounded-lg text-left hover:bg-paper"
onClick={() => {
onModeChange(o.value);
setOpen(false);
}}
>
<span
className={
"text-[13px] " + (o.value === mode ? "font-medium text-accent" : "text-ink")
}
>
{o.label}
{o.value === mode && <span className="ml-1.5"></span>}
</span>
<span className="text-[11px] text-faint leading-snug">{o.description}</span>
</button>
))}
{onUnattendedChange && (
<>
<div className="my-1 border-t border-line" />
<div className="flex items-center gap-2 px-2.5 py-1.5">
<span className="flex-1 min-w-0">
<span className="block text-[13px] text-ink">Send approvals to Inbox</span>
<span className="block text-[11px] text-faint leading-snug">
Approvals &amp; questions go to the Inbox; the agent keeps working.
</span>
</span>
<Toggle
checked={!!unattended}
onChange={onUnattendedChange}
title="Send approvals to the Inbox"
/>
</div>
</>
)}
</div>
</>
)}
</div>
);
}
// A row in the "+" attach menu.
function attachItem(icon: "image" | "file" | "fileCode", label: string, onClick: () => void) {
return (
<button
className="w-full flex items-center gap-2.5 px-3 py-1.5 text-[13px] text-left hover:bg-paper"
onClick={onClick}
>
<Icon name={icon} size={15} className="shrink-0 text-muted" /> {label}
</button>
);
}
function AttachChip({ a, onRemove }: { a: Attachment; onRemove: () => void }) {
return (
<div className={"attach-chip" + (a.kind === "image" ? " img" : "")}>
{a.kind === "image" ? (
<img src={a.data_url} alt={a.name} />
) : (
<>
<Icon name="file" size={13} />
<span className="attach-name">{a.name}</span>
</>
)}
<button className="attach-x" onClick={onRemove} title="Remove">
</button>
</div>
);
}

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