mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-11 14:50:14 +00:00
coworker picker: setup chips above composer, folder pick at send (UX-029)
Per-session coworker+folder chips replace the sidebar split-button picker; code family gets a send-time folder dialog with git-ready temp dirs and Save as project. Builtins ship enabled; user-facing noun is Coworker; personas flag now defaults on.
This commit is contained in:
@@ -30,7 +30,7 @@ test("no topbar opener; the Access header IS the ambient glance; expanding edits
|
||||
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(body.getByTestId("drawer-directories").getByText("Temporary folder")).toBeVisible();
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
|
||||
// Channels is a chat capability, not a two_way one: Slack gets the drill-down, GitHub
|
||||
|
||||
@@ -1,43 +1,93 @@
|
||||
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.)
|
||||
// UX-029: the persona FAMILY still decides workspace behavior, but code-family
|
||||
// enforcement moved from a modal gate at session start to the SEND moment:
|
||||
// code → send with no folder → "Where should … work?" dialog (recents / native
|
||||
// picker / "Start in a temporary folder", git-init'd, created only now)
|
||||
// knowledge → starts orphan on a transparent temporary dir — never gated
|
||||
// The coworker pick lives in the setup chip row above the composer, only before the
|
||||
// first message of a new session; afterwards the row leaves and the facts move to the
|
||||
// session header.
|
||||
|
||||
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();
|
||||
async function newDraftAs(page: import("@playwright/test").Page, coworker: RegExp) {
|
||||
await page.getByText("New session").first().click();
|
||||
await page.getByTestId("coworker-chip").click();
|
||||
await page.locator(".setup-menu").getByRole("button", { name: coworker }).click();
|
||||
}
|
||||
|
||||
test("knowledge persona: new session starts instantly, no folder gate", async ({ page }) => {
|
||||
test("knowledge coworker: new session starts instantly, no gate, no dialog", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.getByPlaceholder(/Ask the coworker/)).toBeVisible();
|
||||
await newDraftAs(page, /Ops Coworker/);
|
||||
|
||||
await startAs(page, /Ops/);
|
||||
await expect(page.locator(".gate-overlay")).toHaveCount(0);
|
||||
await expect(page.getByPlaceholder(/Ask the coworker/)).toBeVisible();
|
||||
const box = page.getByPlaceholder(/Ask the coworker/);
|
||||
await box.fill("hello there");
|
||||
await page.getByRole("button", { name: "Send" }).click();
|
||||
await expect(page.getByText(/Echo: hello there/)).toBeVisible();
|
||||
await expect(page.getByTestId("send-folder-dialog")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("code persona: the folder gate blocks until a project is chosen", async ({ page }) => {
|
||||
test("code coworker: send with no folder asks where to work; temp folder sends the message", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
await expect(page.getByPlaceholder(/Ask the coworker/)).toBeVisible();
|
||||
await newDraftAs(page, /Code Coworker/);
|
||||
|
||||
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.
|
||||
// No modal gate up front — the composer is live and the draft is composable.
|
||||
await expect(page.locator(".gate-overlay")).toHaveCount(0);
|
||||
await expect(page.getByPlaceholder(/Ask the coder/)).toBeVisible();
|
||||
await page.getByPlaceholder(/Ask the coder/).fill("fix the tests");
|
||||
await page.getByRole("button", { name: "Send" }).click();
|
||||
|
||||
const dlg = page.getByTestId("send-folder-dialog");
|
||||
await expect(dlg).toBeVisible();
|
||||
await expect(dlg.getByText("Where should Code Coworker work?")).toBeVisible();
|
||||
await dlg.getByTestId("start-temp-folder").click();
|
||||
|
||||
// The message flies as soon as the choice lands — no second send click, and the local
|
||||
// echo isn't duplicated by turn_start (the notice sits between them).
|
||||
await expect(page.getByText(/Echo: fix the tests/)).toBeVisible();
|
||||
await expect(page.locator(".main-scroll").getByText("fix the tests", { exact: true })).toHaveCount(1);
|
||||
await expect(page.getByText("Temporary folder created · git initialized")).toBeVisible();
|
||||
|
||||
// The raw temp path never shows: header says "Temporary folder" + Save as project….
|
||||
const sub = page.getByTestId("session-subtitle");
|
||||
await expect(sub).toContainText("Code Coworker");
|
||||
await expect(sub).toContainText("Temporary folder");
|
||||
await expect(sub).not.toContainText("ow-temp");
|
||||
await expect(page.getByTestId("save-as-project")).toBeVisible();
|
||||
|
||||
// One-time pick: the setup row left with the first message.
|
||||
await expect(page.getByTestId("setup-row")).toHaveCount(0);
|
||||
|
||||
// A NEW session never inherits the temporary dir — the folder chip starts fresh.
|
||||
await page.getByText("New session").first().click();
|
||||
await expect(page.getByTestId("folder-chip")).toContainText("Choose folder");
|
||||
});
|
||||
|
||||
test("code coworker: Choose a folder… binds the picked project and sends", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await newDraftAs(page, /Code Coworker/);
|
||||
|
||||
await page.getByPlaceholder(/Ask the coder/).fill("hello repo");
|
||||
await page.getByRole("button", { name: "Send" }).click();
|
||||
|
||||
// Native pick is mocked server-side → /tmp/picked-folder.
|
||||
await page.getByTestId("send-folder-dialog").getByRole("button", { name: "Choose a folder…" }).click();
|
||||
await expect(page.getByText(/Echo: hello repo/)).toBeVisible();
|
||||
await expect(page.getByTestId("session-subtitle")).toContainText("picked-folder");
|
||||
await expect(page.getByTestId("save-as-project")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("escape restores the draft instead of losing it", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await newDraftAs(page, /Code Coworker/);
|
||||
|
||||
const box = page.getByPlaceholder(/Ask the coder/);
|
||||
await box.fill("precious draft");
|
||||
await page.getByRole("button", { name: "Send" }).click();
|
||||
await expect(page.getByTestId("send-folder-dialog")).toBeVisible();
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByTestId("send-folder-dialog")).toHaveCount(0);
|
||||
await expect(box).toHaveValue("precious draft");
|
||||
});
|
||||
|
||||
@@ -947,6 +947,15 @@ export async function mockApi(page: import("@playwright/test").Page) {
|
||||
const b = req.postDataJSON();
|
||||
return json({ ok: true, path: b.path, git_branch: "main" });
|
||||
}
|
||||
if (p.endsWith("/v1/workspaces/temp") && m === "POST") {
|
||||
// UX-029: "Start in a temporary folder" — created at send time, git-ready.
|
||||
const b = req.postDataJSON();
|
||||
return json({ ok: true, path: `/tmp/ow-temp/${b.session_id}`, git: b.git !== false });
|
||||
}
|
||||
if (/\/v1\/sessions\/[^/]+\/save-as-project$/.test(p) && m === "POST") {
|
||||
const b = req.postDataJSON();
|
||||
return json({ ok: true, path: b.path });
|
||||
}
|
||||
// must precede the /v1/personas/{id} catch-all (install matches it too)
|
||||
if (p.endsWith("/v1/personas/install") && m === "POST") {
|
||||
const b = req.postDataJSON();
|
||||
|
||||
@@ -6,12 +6,10 @@ 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 page.getByRole("button", { name: "Coworkers", exact: true }).click();
|
||||
await expect(page.getByTestId("gallery-link")).toBeVisible();
|
||||
}
|
||||
|
||||
@@ -65,9 +63,9 @@ test("signed in: featured carousel + list; solo page installs informed; Done ret
|
||||
await expect(page.getByTestId("gallery-team-teaser")).toContainText("coming soon");
|
||||
|
||||
// Search narrows the list.
|
||||
await page.getByPlaceholder("Search personas").fill("recruit");
|
||||
await page.getByPlaceholder("Search coworkers").fill("recruit");
|
||||
await expect(page.getByTestId("gallery-sales")).not.toBeVisible();
|
||||
await page.getByPlaceholder("Search personas").fill("");
|
||||
await page.getByPlaceholder("Search coworkers").fill("");
|
||||
|
||||
// Solo page: pitch + manifest-derived capabilities BEFORE install.
|
||||
await page.getByTestId("gallery-sales").click();
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
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).
|
||||
@@ -15,18 +10,19 @@ test("enabling an installed persona surfaces it in picker + sidebar without relo
|
||||
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");
|
||||
// Disabled install: absent from the composer's coworker picker and the grouped sidebar.
|
||||
await page.getByText("New session").first().click();
|
||||
await page.getByTestId("coworker-chip").click();
|
||||
const menu = page.locator(".setup-menu");
|
||||
await expect(menu).toBeVisible();
|
||||
await expect(menu.getByText("Acme Notes")).toHaveCount(0);
|
||||
await page.locator(".fixed.inset-0.z-20").click(); // close via backdrop
|
||||
await page.locator(".fixed.inset-0.z-20").click({ position: { x: 5, y: 5 } }); // close via backdrop (menu sits over center)
|
||||
await expect(sidebar.getByText("Acme Notes")).toHaveCount(0);
|
||||
|
||||
// Enable it on the Personas page.
|
||||
// Enable it on the Coworkers page.
|
||||
await page.getByTestId("account-row").click();
|
||||
await page.getByRole("button", { name: "Settings", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Personas", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Coworkers", exact: true }).click();
|
||||
const row = page.locator(".divide-y > div").filter({ hasText: "Acme Notes" });
|
||||
// Controlled checkbox: the DOM state flips only after the POST round-trip, so click + expect
|
||||
// (a plain .check() asserts the state synchronously and fails).
|
||||
@@ -36,8 +32,9 @@ test("enabling an installed persona surfaces it in picker + sidebar without relo
|
||||
|
||||
// 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();
|
||||
await page.getByText("New session").first().click();
|
||||
await page.getByTestId("coworker-chip").click();
|
||||
await expect(page.locator(".setup-menu").getByText("Acme Notes")).toBeVisible();
|
||||
});
|
||||
|
||||
// Disable-archives (§18): disabling a persona archives its conversations, so the confirm must
|
||||
@@ -52,7 +49,7 @@ test("disabling a persona with conversations asks first, then archives them", as
|
||||
|
||||
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 page.getByRole("button", { name: "Coworkers", exact: true }).click();
|
||||
const row = page.locator(".divide-y > div").filter({ hasText: "Ops Coworker" });
|
||||
const enabled = row.getByRole("checkbox", { name: "Enabled" });
|
||||
|
||||
@@ -78,7 +75,7 @@ test("disabling a persona with no conversations skips the confirm", async ({ pag
|
||||
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 page.getByRole("button", { name: "Coworkers", exact: true }).click();
|
||||
const row = page.locator(".divide-y > div").filter({ hasText: "Code" });
|
||||
const enabled = row.getByRole("checkbox", { name: "Enabled" });
|
||||
await enabled.click();
|
||||
|
||||
@@ -14,8 +14,8 @@ test("working directories: add folders with the read-only / read-write gate", as
|
||||
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();
|
||||
// The primary is the writable scratch workspace (Cowork shows it as "Temporary folder").
|
||||
await expect(dirs.getByText("Temporary folder")).toBeVisible();
|
||||
|
||||
// Add a folder — the gate defaults to read-only (Allow writes OFF). The Browse button works
|
||||
// in the BROWSER too (sidecar-opened native picker; owner report 2026-07-04).
|
||||
|
||||
@@ -33,7 +33,7 @@ test("top-left cluster renders only while the sidebar is collapsed", async ({ pa
|
||||
await expect(page.getByTestId("topbar-cluster")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("facts subtitle: absent on a fresh session, model-only after the first turn, inert", async ({
|
||||
test("facts subtitle: absent on a fresh session, coworker + model after the first turn, inert", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
@@ -51,10 +51,10 @@ test("facts subtitle: absent on a fresh session, model-only after the first turn
|
||||
await page.getByRole("button", { name: "Send" }).click();
|
||||
await expect(page.getByText(/Echo: hello/)).toBeVisible();
|
||||
|
||||
// Model only — no persona name (owner ask 2026-07-22: personas are hidden this release),
|
||||
// and the subtitle is a plain fact line, not a button to the persona page.
|
||||
// Coworker + model (UX-029 restored the coworker name — the picker shipped), and the
|
||||
// subtitle is a plain fact line, not a button to the persona page.
|
||||
const sub = page.getByTestId("session-subtitle");
|
||||
await expect(sub).toHaveText("Claude Opus 4.8");
|
||||
await expect(sub).toHaveText("Coworker · Claude Opus 4.8");
|
||||
await expect(page.locator(".dd").filter({ hasText: "Claude Opus 4.8" })).toBeVisible();
|
||||
await sub.click();
|
||||
await expect(page.getByRole("button", { name: "Back", exact: true })).toHaveCount(0);
|
||||
|
||||
@@ -2,7 +2,7 @@ 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.
|
||||
// Files is a card inside General; Coworkers ships on (flag "0" hides it).
|
||||
test("Settings opens as a full page and navigates sections", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
|
||||
@@ -15,9 +15,9 @@ test("Settings opens as a full page and navigates sections", async ({ page }) =>
|
||||
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.
|
||||
// Folded tabs: Files is a General card now; Coworkers ships as its own tab (UX-029).
|
||||
await expect(page.getByRole("button", { name: "Files", exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Personas", exact: true })).toHaveCount(0);
|
||||
await expect(page.getByRole("button", { name: "Coworkers", exact: true })).toBeVisible();
|
||||
|
||||
// The Files card lives inside General.
|
||||
await expect(page.getByText("Each conversation gets its own folder")).toBeVisible();
|
||||
@@ -26,14 +26,22 @@ test("Settings opens as a full page and navigates sections", async ({ page }) =>
|
||||
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"));
|
||||
// The flag's "0" escape hatch hides the tab again (the default is on — UX-029).
|
||||
test("Settings: Coworkers tab opens by default; flag \"0\" hides it", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await page.getByTestId("account-row").click();
|
||||
await page.getByRole("button", { name: "Settings", exact: true }).click();
|
||||
await page.getByRole("button", { name: "Personas", exact: true }).click();
|
||||
await expect(page.getByText("Add personas")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Coworkers", exact: true }).click();
|
||||
await expect(page.getByText("Add coworkers")).toBeVisible();
|
||||
});
|
||||
|
||||
test("Settings: the flag escape hatch hides the Coworkers tab", async ({ page }) => {
|
||||
await page.addInitScript(() => localStorage.setItem("ocw.flag.personas", "0"));
|
||||
await page.goto("/");
|
||||
await page.getByTestId("account-row").click();
|
||||
await page.getByRole("button", { name: "Settings", exact: true }).click();
|
||||
await expect(page.getByRole("heading", { name: "General" })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: "Coworkers", exact: true })).toHaveCount(0);
|
||||
});
|
||||
|
||||
// UX-021: Settings ▸ Models is the shared provider gallery (§39 components). Cards wear
|
||||
|
||||
Reference in New Issue
Block a user