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>
@@ -0,0 +1,9 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.local
|
||||
.DS_Store
|
||||
|
||||
# Playwright
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/e2e/_dbg.png
|
||||
@@ -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)
|
||||
```
|
||||
|
After Width: | Height: | Size: 21 KiB |
@@ -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"]));
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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.
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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`.
|
||||
```
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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 4–5 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();
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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.");
|
||||
});
|
||||
@@ -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("You’re 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();
|
||||
});
|
||||
@@ -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", "••••••••");
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,3 @@
|
||||
/target
|
||||
/gen/schemas
|
||||
/binaries
|
||||
@@ -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" }
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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>
|
||||
|
After Width: | Height: | Size: 5.5 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 356 B |
@@ -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());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||