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:
Rohit C Prasad
2026-08-10 21:43:02 -07:00
committed by Rohit P
parent 9702c86c7f
commit 3d13c7d699
28 changed files with 923 additions and 318 deletions
+7 -3
View File
@@ -219,11 +219,15 @@ class PersonaRegistry:
return self._entries.get(persona_id) return self._entries.get(persona_id)
def is_enabled(self, persona_id: str) -> bool: def is_enabled(self, persona_id: str) -> bool:
# No user choice recorded → only the default persona ships enabled (owner call, # Explicit state (either way) always wins. Absent a user choice, BUILT-IN personas
# 2026-07-09): a fresh install is Coworker-only, everything else is opt-in from # ship enabled — the composer picker is their front door (UX-029, supersedes the
# Settings ▸ Personas. Explicit state (either way) always wins. # 2026-07-09 Coworker-only default that fit the old hidden ▾ menu). Installed
# third-party personas stay disabled until the user consents from the risk screen.
if persona_id in self._enabled: if persona_id in self._enabled:
return bool(self._enabled[persona_id]) return bool(self._enabled[persona_id])
entry = self._entries.get(persona_id)
if entry is not None and entry.builtin:
return True
return persona_id == self._default or persona_id == DEFAULT_PERSONA_ID return persona_id == self._default or persona_id == DEFAULT_PERSONA_ID
def is_surfaced(self, persona_id: str) -> bool: def is_surfaced(self, persona_id: str) -> bool:
+22
View File
@@ -641,6 +641,21 @@ def create_app(manager: SessionManager) -> FastAPI:
trusted=bool((body or {}).get("trusted", False)), trusted=bool((body or {}).get("trusted", False)),
) )
@app.post("/v1/workspaces/temp")
def provision_temp_workspace(body: dict) -> dict[str, Any]:
# UX-029: a code-family session starting "in a temporary folder" — created only
# at send time, with git ready. Knowledge families keep their auto-provisioned dir.
return manager.provision_temp_workspace(
str((body or {}).get("session_id", "")),
git=bool((body or {}).get("git", True)),
)
@app.post("/v1/sessions/{session_id}/save-as-project")
def save_session_as_project(session_id: str, body: dict) -> dict[str, Any]:
# UX-029 "Save as project…": move the temporary folder somewhere real. The GUI
# reconnects afterwards so the engine rebinds to the new path.
return manager.save_temp_as_project(session_id, str((body or {}).get("path", "")))
@app.post("/v1/workspaces/pick") @app.post("/v1/workspaces/pick")
async def pick_workspace() -> dict[str, Any]: async def pick_workspace() -> dict[str, Any]:
# Native folder picker opened by the LOCAL sidecar (browser GUIs can't get absolute # Native folder picker opened by the LOCAL sidecar (browser GUIs can't get absolute
@@ -1800,6 +1815,13 @@ def create_app(manager: SessionManager) -> FastAPI:
if getattr(engine, "executor", None) if getattr(engine, "executor", None)
else None else None
), ),
# UX-029: the GUI never shows a temporary folder's raw path — this flag
# is how it knows to say "Temporary folder" (and offer Save as project).
"temp_workspace": manager.is_temp_workspace(
str(getattr(engine, "executor").cwd)
if getattr(engine, "executor", None)
else None
),
"command_trust": manager.workspace_command_trust( "command_trust": manager.workspace_command_trust(
str(getattr(engine, "audit_context", {}).get("workspace", "")) str(getattr(engine, "audit_context", {}).get("workspace", ""))
), ),
+69
View File
@@ -361,6 +361,75 @@ class SessionManager:
d.mkdir(parents=True, exist_ok=True) d.mkdir(parents=True, exist_ok=True)
return str(d.resolve()) return str(d.resolve())
_SESSION_ID_RE = re.compile(r"^[A-Za-z0-9_.-]{1,64}$")
def is_temp_workspace(self, path: Optional[str]) -> bool:
"""True when `path` is a per-conversation temporary directory (lives under the
scratch base). The GUI uses this to label the folder "Temporary folder" instead
of exposing its raw path."""
if not path:
return False
try:
return (
Path(path).expanduser().resolve().is_relative_to(self.scratch_base().resolve())
)
except OSError:
return False
def provision_temp_workspace(self, session_id: str, *, git: bool = True) -> dict[str, Any]:
"""UX-029 "Start in a temporary folder": create the conversation's temporary
directory at SEND time (not connect) and, for code-family work, make git ready.
Idempotent — re-sending against an existing dir is a no-op."""
if not self._SESSION_ID_RE.match(session_id or "") or session_id in {".", ".."}:
return {"ok": False, "error": "invalid session id"}
path = self._provision_scratch(session_id)
if git and not (Path(path) / ".git").is_dir():
try:
subprocess.run(
["git", "init", "-q"],
cwd=path,
capture_output=True,
timeout=10,
check=False,
)
except (OSError, subprocess.SubprocessError):
pass # no git on PATH → still a usable folder, just not a repo
return {"ok": True, "path": path, "git": (Path(path) / ".git").is_dir()}
def save_temp_as_project(self, session_id: str, dest: str) -> dict[str, Any]:
"""UX-029 "Save as project…": move a session's temporary folder to a real
location and rebind the session there. The cached engine is dropped so the next
connect rebuilds against the new path — callers must reconnect after this."""
if not dest or not dest.strip():
return {"ok": False, "error": "no destination folder"}
record = self.session_store.load(session_id)
src = record.workspace if record and record.workspace else None
if not src:
engine = self._engines.get(session_id)
executor = getattr(engine, "executor", None) if engine else None
src = str(executor.cwd) if executor else None
if not src or not self.is_temp_workspace(src) or not Path(src).is_dir():
return {"ok": False, "error": "this session is not in a temporary folder"}
if self.is_running(session_id):
return {"ok": False, "error": "wait for the current task to finish first"}
d = Path(dest).expanduser()
if d.exists():
if not d.is_dir() or any(d.iterdir()):
return {"ok": False, "error": "destination must be a new or empty folder"}
d.rmdir() # shutil.move into an existing dir would nest src inside it
try:
d.parent.mkdir(parents=True, exist_ok=True)
shutil.move(src, str(d))
except OSError as e:
return {"ok": False, "error": f"could not move the folder: {e}"}
new_path = str(d.resolve())
if record:
record.workspace = new_path
self.session_store.save(record)
self._engines.pop(session_id, None)
self.session_store.touch_workspace(new_path)
return {"ok": True, "path": new_path}
def resolve_workspace(self, requested: Optional[str]) -> Optional[str]: def resolve_workspace(self, requested: Optional[str]) -> Optional[str]:
if requested: if requested:
p = Path(requested).expanduser() p = Path(requested).expanduser()
+1 -1
View File
@@ -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("Sources")).toBeVisible();
await expect(body.getByText("Slack", { exact: true })).toBeVisible(); await expect(body.getByText("Slack", { exact: true })).toBeVisible();
await expect(body.getByText("email context for morning summaries")).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); await expect(page.getByRole("dialog")).toHaveCount(0);
// Channels is a chat capability, not a two_way one: Slack gets the drill-down, GitHub // Channels is a chat capability, not a two_way one: Slack gets the drill-down, GitHub
+79 -29
View File
@@ -1,43 +1,93 @@
import { test, expect } from "./fixtures"; import { test, expect } from "./fixtures";
// §16 workspace collapse: the persona FAMILY alone decides the workspace behavior. // UX-029: the persona FAMILY still decides workspace behavior, but code-family
// code → an explicit project folder, enforced by the FolderGate (no chat-behind-it escape) // enforcement moved from a modal gate at session start to the SEND moment:
// knowledge → starts orphan on a transparent scratch dir — never gated // code → send with no folder → "Where should … work?" dialog (recents / native
// (The mock's Ops persona is knowledge-family with zero sessions, so picking it exercises the // picker / "Start in a temporary folder", git-init'd, created only now)
// brand-new-session path, not a resume.) // 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 newDraftAs(page: import("@playwright/test").Page, coworker: RegExp) {
await page.getByText("New session").first().click();
async function startAs(page: import("@playwright/test").Page, persona: RegExp) { await page.getByTestId("coworker-chip").click();
await page.getByLabel("Choose a persona").click(); await page.locator(".setup-menu").getByRole("button", { name: coworker }).click();
await personaMenu(page).getByRole("button", { name: persona }).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 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.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 page.goto("/");
await expect(page.getByPlaceholder(/Ask the coworker/)).toBeVisible(); await newDraftAs(page, /Code Coworker/);
await startAs(page, /Code/); // No modal gate up front — the composer is live and the draft is composable.
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.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");
}); });
+9
View File
@@ -947,6 +947,15 @@ export async function mockApi(page: import("@playwright/test").Page) {
const b = req.postDataJSON(); const b = req.postDataJSON();
return json({ ok: true, path: b.path, git_branch: "main" }); 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) // must precede the /v1/personas/{id} catch-all (install matches it too)
if (p.endsWith("/v1/personas/install") && m === "POST") { if (p.endsWith("/v1/personas/install") && m === "POST") {
const b = req.postDataJSON(); const b = req.postDataJSON();
+3 -5
View File
@@ -6,12 +6,10 @@ import { expect } from "@playwright/test";
import { test } from "./fixtures"; import { test } from "./fixtures";
async function openPersonas(page) { 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.goto("/");
await page.getByTestId("account-row").click(); await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Settings", exact: true }).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(); 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"); await expect(page.getByTestId("gallery-team-teaser")).toContainText("coming soon");
// Search narrows the list. // 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 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. // Solo page: pitch + manifest-derived capabilities BEFORE install.
await page.getByTestId("gallery-sales").click(); await page.getByTestId("gallery-sales").click();
+12 -15
View File
@@ -1,10 +1,5 @@
import { test, expect } from "./fixtures"; 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 // 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 // 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). // 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("/"); await page.goto("/");
const sidebar = page.locator(".sidebar"); const sidebar = page.locator(".sidebar");
// Disabled install: absent from the persona picker and the grouped sidebar. // Disabled install: absent from the composer's coworker picker and the grouped sidebar.
await page.getByLabel("Choose a persona").click(); await page.getByText("New session").first().click();
const menu = page.locator(".newsplit-menu"); await page.getByTestId("coworker-chip").click();
const menu = page.locator(".setup-menu");
await expect(menu).toBeVisible(); await expect(menu).toBeVisible();
await expect(menu.getByText("Acme Notes")).toHaveCount(0); 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); 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.getByTestId("account-row").click();
await page.getByRole("button", { name: "Settings", exact: true }).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" }); 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 // Controlled checkbox: the DOM state flips only after the POST round-trip, so click + expect
// (a plain .check() asserts the state synchronously and fails). // (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. // No reload: the sidebar group and the picker both pick it up via PERSONAS_CHANGED.
await expect(sidebar.getByText("Acme Notes")).toBeVisible(); await expect(sidebar.getByText("Acme Notes")).toBeVisible();
await page.getByLabel("Choose a persona").click(); await page.getByText("New session").first().click();
await expect(page.locator(".newsplit-menu").getByText("Acme Notes")).toBeVisible(); 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 // 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.getByTestId("account-row").click();
await page.getByRole("button", { name: "Settings", exact: true }).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 row = page.locator(".divide-y > div").filter({ hasText: "Ops Coworker" });
const enabled = row.getByRole("checkbox", { name: "Enabled" }); 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.goto("/");
await page.getByTestId("account-row").click(); await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Settings", exact: true }).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 row = page.locator(".divide-y > div").filter({ hasText: "Code" });
const enabled = row.getByRole("checkbox", { name: "Enabled" }); const enabled = row.getByRole("checkbox", { name: "Enabled" });
await enabled.click(); await enabled.click();
+2 -2
View File
@@ -14,8 +14,8 @@ test("working directories: add folders with the read-only / read-write gate", as
const dirs = page.getByTestId("drawer-directories"); const dirs = page.getByTestId("drawer-directories");
await expect(dirs.getByText("Folders")).toBeVisible(); await expect(dirs.getByText("Folders")).toBeVisible();
// The primary is the writable scratch workspace (Cowork shows it as "Temporary space"). // The primary is the writable scratch workspace (Cowork shows it as "Temporary folder").
await expect(dirs.getByText("Temporary space")).toBeVisible(); await expect(dirs.getByText("Temporary folder")).toBeVisible();
// Add a folder — the gate defaults to read-only (Allow writes OFF). The Browse button works // 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). // in the BROWSER too (sidecar-opened native picker; owner report 2026-07-04).
+4 -4
View File
@@ -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); 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, page,
}) => { }) => {
await page.goto("/"); 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 page.getByRole("button", { name: "Send" }).click();
await expect(page.getByText(/Echo: hello/)).toBeVisible(); await expect(page.getByText(/Echo: hello/)).toBeVisible();
// Model only — no persona name (owner ask 2026-07-22: personas are hidden this release), // Coworker + model (UX-029 restored the coworker name — the picker shipped), and the
// and the subtitle is a plain fact line, not a button to the persona page. // subtitle is a plain fact line, not a button to the persona page.
const sub = page.getByTestId("session-subtitle"); 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 expect(page.locator(".dd").filter({ hasText: "Claude Opus 4.8" })).toBeVisible();
await sub.click(); await sub.click();
await expect(page.getByRole("button", { name: "Back", exact: true })).toHaveCount(0); await expect(page.getByRole("button", { name: "Back", exact: true })).toHaveCount(0);
+16 -8
View File
@@ -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 // 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. // 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 }) => { test("Settings opens as a full page and navigates sections", async ({ page }) => {
await page.goto("/"); 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"]) { for (const label of ["General", "Models", "Voice input"]) {
await expect(page.getByRole("button", { name: label, exact: true })).toBeVisible(); 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: "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. // The Files card lives inside General.
await expect(page.getByText("Each conversation gets its own folder")).toBeVisible(); 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(); await expect(page.getByTestId("set-provider-openai")).toBeVisible();
}); });
// The launch flag brings the Personas tab back (the gallery/persona suites rely on it). // The flag's "0" escape hatch hides the tab again (the default is on — UX-029).
test("Settings: Personas tab returns behind the launch flag", async ({ page }) => { test("Settings: Coworkers tab opens by default; flag \"0\" hides it", async ({ page }) => {
await page.addInitScript(() => localStorage.setItem("ocw.flag.personas", "1"));
await page.goto("/"); await page.goto("/");
await page.getByTestId("account-row").click(); await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Settings", exact: true }).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.getByText("Add personas")).toBeVisible(); 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 // UX-021: Settings ▸ Models is the shared provider gallery (§39 components). Cards wear
+223 -32
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState, type PointerEvent } from "react"; import { useCallback, useEffect, useRef, useState, type PointerEvent } from "react";
import { import {
announceInboxUnlock, announceInboxUnlock,
createTempWorkspace,
finalizeAutomationRun, finalizeAutomationRun,
getArtifacts, getArtifacts,
getHealth, getHealth,
@@ -21,6 +22,7 @@ import {
deleteSession, deleteSession,
renameSession, renameSession,
runAutomation, runAutomation,
saveSessionAsProject,
setSessionFlags, setSessionFlags,
setUnattended, setUnattended,
Session, Session,
@@ -40,13 +42,13 @@ import type {
TodoItem, TodoItem,
WsEvent, WsEvent,
} from "./types"; } from "./types";
import { isProjectScoped } from "./personaScope"; import { fullPersonaName, isProjectScoped } from "./personaScope";
import { baseName } from "./paths"; import { baseName } from "./paths";
import { itemsFromMessages } from "./itemsFromMessages"; import { itemsFromMessages } from "./itemsFromMessages";
import { addTurnUsage, emptyUsage, usageFromMessages } from "./usage"; import { addTurnUsage, emptyUsage, usageFromMessages } from "./usage";
import { streamMode } from "./streamGate"; import { streamMode } from "./streamGate";
import { InboxItemCard } from "./components/InboxItemCard"; import { InboxItemCard } from "./components/InboxItemCard";
import { isTauri, platformOS, startWindowDrag } from "./tauri"; import { chooseFolder, isTauri, platformOS, startWindowDrag } from "./tauri";
import { Icon } from "./components/Icon"; import { Icon } from "./components/Icon";
import { Sidebar } from "./components/Sidebar"; import { Sidebar } from "./components/Sidebar";
import { ThinkingBlock, Transcript } from "./components/Transcript"; import { ThinkingBlock, Transcript } from "./components/Transcript";
@@ -55,6 +57,8 @@ import { Markdown } from "./components/Markdown";
import { SearchModal } from "./components/SearchModal"; import { SearchModal } from "./components/SearchModal";
import { SessionIntro } from "./components/SessionIntro"; import { SessionIntro } from "./components/SessionIntro";
import { FolderGate } from "./components/FolderGate"; import { FolderGate } from "./components/FolderGate";
import { SessionSetupRow } from "./components/SessionSetupRow";
import { SendFolderDialog } from "./components/SendFolderDialog";
import { Onboarding } from "./components/Onboarding"; import { Onboarding } from "./components/Onboarding";
import { UpdateBanner } from "./components/UpdateBanner"; import { UpdateBanner } from "./components/UpdateBanner";
import { ScheduledView } from "./components/ScheduledView"; import { ScheduledView } from "./components/ScheduledView";
@@ -158,6 +162,20 @@ function fallbackWorkspace(current: string | null, projects: RecentWorkspace[]):
export function App() { export function App() {
const [workspace, setWorkspace] = useState<string | null>(null); const [workspace, setWorkspace] = useState<string | null>(null);
const [branch, setBranch] = useState<string | null>(null); const [branch, setBranch] = useState<string | null>(null);
// UX-029: the active session runs in a temporary folder (never show its raw path —
// the header says "Temporary folder" and offers Save as project…). Set locally when a
// temp dir is created at send, corrected by every `ready` event (server truth).
const [tempWorkspace, setTempWorkspace] = useState(false);
// UX-029 send-time folder enforcement: the stashed message while the folder dialog is
// up. The message goes out the moment the dialog resolves; Escape restores the draft.
const [sendGate, setSendGate] = useState<{
text: string;
attachments?: Attachment[];
skill?: string;
} | null>(null);
// Bumped to force a socket rebuild on the SAME session id (Save as project… moves the
// folder server-side; the engine rebinds on reconnect).
const [connectNonce, setConnectNonce] = useState(0);
const [showGate, setShowGate] = useState(false); const [showGate, setShowGate] = useState(false);
const [workspaceTrustRequest, setWorkspaceTrustRequest] = const [workspaceTrustRequest, setWorkspaceTrustRequest] =
useState<WorkspaceCommandTrust | null>(null); useState<WorkspaceCommandTrust | null>(null);
@@ -321,7 +339,12 @@ export function App() {
// code-family persona gates a folder like Code, and a knowledge persona starts orphan like Cowork). // code-family persona gates a folder like Code, and a knowledge persona starts orphan like Cowork).
const [personas, setPersonas] = useState<Persona[] | null>(null); const [personas, setPersonas] = useState<Persona[] | null>(null);
useEffect(() => { useEffect(() => {
getPersonas().then(setPersonas).catch(() => {}); const load = () => getPersonas().then(setPersonas).catch(() => {});
load();
// The composer's coworker picker is always mounted on a fresh session — refetch on
// mutations (enable/install from Settings) instead of going stale.
window.addEventListener(PERSONAS_CHANGED, load);
return () => window.removeEventListener(PERSONAS_CHANGED, load);
}, []); }, []);
const personaOf = (a: string) => personas?.find((p) => p.id === a); const personaOf = (a: string) => personas?.find((p) => p.id === a);
@@ -378,8 +401,15 @@ export function App() {
const sessionRef = useRef<Session | null>(null); const sessionRef = useRef<Session | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null); const scrollRef = useRef<HTMLDivElement | null>(null);
// A prompt to auto-send once the next session connects (used by "Run now"). // A message to auto-send once the next session connects "Run now" task prompts, and
const pendingPromptRef = useRef<string | null>(null); // UX-029's deferred first send (folder resolved at send time → reconnect → message goes).
const pendingPromptRef = useRef<{
text: string;
attachments?: Attachment[];
skill?: string;
model?: string;
notice?: string; // e.g. "Temporary folder created · git initialized", shown after the message
} | null>(null);
// The in-flight manual run to finalize after its first turn ({taskId, runId, sessionId}). // The in-flight manual run to finalize after its first turn ({taskId, runId, sessionId}).
const activeRunRef = useRef<{ taskId: string; runId: string; sessionId: string } | null>(null); const activeRunRef = useRef<{ taskId: string; runId: string; sessionId: string } | null>(null);
@@ -549,15 +579,15 @@ export function App() {
return () => window.removeEventListener(PERSONAS_CHANGED, onPersonas); return () => window.removeEventListener(PERSONAS_CHANGED, onPersonas);
}, [refreshSessions]); }, [refreshSessions]);
// If the active surface isn't visible (hidden in Settings, or a resumed session landed on a // If the active persona is DISABLED (turned off in Settings, or a resumed session landed
// hidden surface), fall back to Cowork (always visible). Watches both agent and surfaces so it // on one), fall back to Cowork. This used to key on the legacy sidebar-visibility prefs
// corrects regardless of which settled last. // (show_chat/show_code) — with the composer picker shipped (UX-029), enablement is the
// one visibility axis, and a deliberately picked coworker must never be reverted.
useEffect(() => { useEffect(() => {
if ((agent === "chat" && !surfaces.chat) || (agent === "code" && !surfaces.code)) { const p = personaOf(agent);
switchAgent("cowork"); if (p && !p.enabled) switchAgent("cowork");
}
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [agent, surfaces]); }, [agent, personas]);
useEffect(() => { useEffect(() => {
if (surface === "session") rememberLastSession(agent, sessionId, workspace); if (surface === "session") rememberLastSession(agent, sessionId, workspace);
@@ -600,6 +630,8 @@ export function App() {
if (d.command_trust?.required) setWorkspaceTrustRequest(d.command_trust); if (d.command_trust?.required) setWorkspaceTrustRequest(d.command_trust);
// Cowork: adopt the server-provisioned scratch dir (only when we don't already have one). // Cowork: adopt the server-provisioned scratch dir (only when we don't already have one).
if (d.workspace) setWorkspace((cur) => cur || d.workspace); if (d.workspace) setWorkspace((cur) => cur || d.workspace);
// UX-029: server truth on whether this session runs in a temporary folder.
if (typeof d.temp_workspace === "boolean") setTempWorkspace(d.temp_workspace);
break; break;
case "turn_start": case "turn_start":
setRunning(true); setRunning(true);
@@ -622,7 +654,11 @@ export function App() {
// `input` is model-facing. Surface/dedupe on what the user actually sees. // `input` is model-facing. Surface/dedupe on what the user actually sees.
const shown = (typeof d.display === "string" && d.display) || (d.input as string); const shown = (typeof d.display === "string" && d.display) || (d.input as string);
setItems((p) => { setItems((p) => {
const last = p[p.length - 1]; // Look past trailing notices — the UX-029 "Temporary folder created" line
// sits between the local echo and this event's arrival.
let i = p.length - 1;
while (i >= 0 && p[i].kind === "notice") i--;
const last = p[i];
return last && last.kind === "user" && last.text === shown return last && last.kind === "user" && last.text === shown
? p ? p
: [...p, { kind: "user", text: shown, ts: Date.now() / 1000 }]; : [...p, { kind: "user", text: shown, ts: Date.now() / 1000 }];
@@ -795,12 +831,20 @@ export function App() {
onEvent: handleEvent, onEvent: handleEvent,
onOpen: () => { onOpen: () => {
setConnected(true); setConnected(true);
// Auto-send the task prompt once a "Run now" session connects. // Auto-send the pending message once the session connects ("Run now" prompts and
// UX-029's deferred first send).
const p = pendingPromptRef.current; const p = pendingPromptRef.current;
if (p) { if (p) {
pendingPromptRef.current = null; pendingPromptRef.current = null;
setItems((prev) => [...prev, { kind: "user", text: p, ts: Date.now() / 1000 }]); const shown = p.skill ? `/${p.skill}${p.text ? ` ${p.text}` : ""}` : p.text;
sessionRef.current?.userMessage(p); setItems((prev) => [
...prev,
{ kind: "user", text: shown, attachments: p.attachments, ts: Date.now() / 1000 },
...(p.notice
? [{ kind: "notice", tone: "info", text: p.notice } as Item]
: []),
]);
sessionRef.current?.userMessage(p.text, p.attachments, p.model, p.skill);
} }
}, },
onClose: () => setConnected(false), onClose: () => setConnected(false),
@@ -815,7 +859,7 @@ export function App() {
// first connect, dropping the user's first message (the "send twice" bug). The scratch // first connect, dropping the user's first message (the "send twice" bug). The scratch
// dir is deterministic from `sessionId` server-side, so skipping that reconnect is safe. // dir is deterministic from `sessionId` server-side, so skipping that reconnect is safe.
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [booting, sessionId, agent, refreshSessions]); }, [booting, sessionId, agent, refreshSessions, connectNonce]);
// Stream-following (FB-004): auto-scroll only while the user is AT the bottom, so scrolling // Stream-following (FB-004): auto-scroll only while the user is AT the bottom, so scrolling
// up to read during a streaming turn sticks. `atBottomRef` is the live truth (per scroll // up to read during a streaming turn sticks. `atBottomRef` is the live truth (per scroll
@@ -890,6 +934,13 @@ export function App() {
}, [surface, sessionId, browserRefreshKey, markUnattended]); }, [surface, sessionId, browserRefreshKey, markUnattended]);
const send = (text: string, attachments?: Attachment[], skill?: string) => { const send = (text: string, attachments?: Attachment[], skill?: string) => {
// UX-029: folder enforcement AT SEND. A code-family session with no folder has no
// socket yet (the connect effect waits) — stash the message and ask where to work;
// it goes out the moment the dialog resolves.
if (gatesWorkspace(agent) && !workspace) {
setSendGate({ text, attachments, skill });
return;
}
// Force-run shows exactly what the user typed: "/name rest". Must match the server's // Force-run shows exactly what the user typed: "/name rest". Must match the server's
// `display` sidecar formula so the turn_start dedupe recognizes the local echo. // `display` sidecar formula so the turn_start dedupe recognizes the local echo.
const shown = skill ? `/${skill}${text ? ` ${text}` : ""}` : text; const shown = skill ? `/${skill}${text ? ` ${text}` : ""}` : text;
@@ -957,18 +1008,116 @@ export function App() {
if (target !== agent) { if (target !== agent) {
setAgent(target); setAgent(target);
if (gatesWorkspace(target)) { if (gatesWorkspace(target)) {
// Never inherit the previous persona's folder — it may be a scratch dir. Clearing it // Never inherit the previous persona's folder — it may be a scratch dir. Clearing
// also blocks the connection effect, so nothing can chat behind the open gate. // it also blocks the connection effect; the setup row's folder chip (or the
// send-time dialog) provides the folder — no modal gate up front (UX-029).
setWorkspace(null); setWorkspace(null);
setBranch(null); setBranch(null);
setShowGate(true); }
} else setShowGate(false); setShowGate(false);
} }
// Knowledge family: a new conversation starts fresh (orphan) — clear the workspace so the // Knowledge family: a new conversation starts fresh (orphan) — clear the workspace so the
// server provisions a NEW scratch dir for the new session id. Code keeps its repo. // server provisions a NEW scratch dir for the new session id. Code keeps its repo — but
if (!gatesWorkspace(target)) setWorkspace(null); // never a TEMPORARY dir (per-conversation by definition; the next session picks anew).
if (!gatesWorkspace(target) || tempWorkspace) {
setWorkspace(null);
setBranch(null);
}
setTempWorkspace(false);
setSessionId(newId()); setSessionId(newId());
}; };
// UX-029: re-target the DRAFT session (no messages yet) to another coworker. Unlike
// switchAgent this never resumes that coworker's last conversation — the user is
// composing a new one. A fresh id keeps knowledge families' per-conversation scratch
// dirs clean and re-triggers the connection effect.
const pickCoworker = (id: string) => {
if (id === agent) return;
setAgent(id);
setWorkspace(null);
setBranch(null);
setTempWorkspace(false);
setShowGate(false);
setSessionId(newId());
};
// UX-029: the setup row's folder chip — bind the draft to a folder before the first
// message. A fresh id re-triggers the connection effect with the folder attached.
const pickDraftFolder = (path: string, b?: string | null) => {
setWorkspace(path);
setBranch(b ?? null);
setTempWorkspace(false);
setSessionId(newId());
getRecentWorkspaces().then(setProjects).catch(() => {});
};
// UX-029 send-time dialog resolutions: bind the folder, park the stashed message for
// the reconnect's onOpen, and let it fly. The user's send already happened — no second
// click needed.
const resolveSendFolder = (path: string, b?: string | null) => {
const gate = sendGate;
if (!gate) return;
setSendGate(null);
setWorkspace(path);
setBranch(b ?? null);
setTempWorkspace(false);
pendingPromptRef.current = { ...gate, model };
setSessionId(newId());
getRecentWorkspaces().then(setProjects).catch(() => {});
};
const startTempAndSend = async () => {
const gate = sendGate;
if (!gate) return;
const sid = newId();
const res = await createTempWorkspace(sid, true);
if (!res.ok || !res.path) {
setSendGate(null);
setItems((p) => [
...p,
{ kind: "notice", tone: "warn", text: res.error || "Could not create a temporary folder." },
]);
prefillComposer(gate.skill ? `/${gate.skill} ${gate.text}` : gate.text, gate.attachments);
return;
}
setSendGate(null);
setWorkspace(res.path);
setBranch(null);
setTempWorkspace(true);
pendingPromptRef.current = {
...gate,
model,
notice: res.git ? "Temporary folder created · git initialized" : "Temporary folder created",
};
setSessionId(sid);
};
const cancelSendGate = () => {
const gate = sendGate;
setSendGate(null);
// Give the draft back — the composer cleared it when the user hit send.
if (gate) prefillComposer(gate.skill ? `/${gate.skill} ${gate.text}` : gate.text, gate.attachments);
};
// UX-029 "Save as project…": move the temporary folder somewhere real, then reconnect
// so the engine rebinds to the new path (same session id — the transcript stays).
const saveAsProject = async () => {
if (running) return;
const dest = await chooseFolder();
if (!dest) return;
const res = await saveSessionAsProject(sessionId, dest);
if (!res.ok || !res.path) {
setItems((p) => [
...p,
{ kind: "notice", tone: "warn", text: res.error || "Could not save as a project." },
]);
return;
}
const newPath = res.path;
setWorkspace(newPath);
setBranch(null);
setTempWorkspace(false);
setItems((p) => [
...p,
{ kind: "notice", tone: "info", text: `Saved as a project — now working in ${baseName(newPath)}.` },
]);
setConnectNonce((n) => n + 1);
refreshSessions();
};
// Inbox → session: the item carries its session's workspace/agent, so open it directly. // Inbox → session: the item carries its session's workspace/agent, so open it directly.
// UX-026: 5s top-right toast when a SCHEDULED automation run starts (never for // UX-026: 5s top-right toast when a SCHEDULED automation run starts (never for
// manual Run-now — the user is already watching). Rides the app-wide /ws/events // manual Run-now — the user is already watching). Rides the app-wide /ws/events
@@ -1016,6 +1165,7 @@ export function App() {
setStreaming(""); setStreaming("");
setRunning(false); setRunning(false);
if (ag) setAgent(ag); if (ag) setAgent(ag);
setTempWorkspace(false); // the `ready` event restores the truth for temp sessions
if (!gatesWorkspace(ag)) setShowGate(false); if (!gatesWorkspace(ag)) setShowGate(false);
if (ws && ws !== workspace) { if (ws && ws !== workspace) {
setWorkspace(ws); // switch project to the session's folder setWorkspace(ws); // switch project to the session's folder
@@ -1048,8 +1198,9 @@ export function App() {
// The live workspace is only a valid fallback for a gated persona if it came from // The live workspace is only a valid fallback for a gated persona if it came from
// another gated persona — a knowledge persona's workspace is a scratch dir, and a // another gated persona — a knowledge persona's workspace is a scratch dir, and a
// code-family session must never adopt one. (`agent` is still the previous persona here.) // code-family session must never adopt one. Same for a code session's TEMPORARY dir:
const inheritable = gatesWorkspace(agent) ? workspace : null; // per-conversation, never inherited. (`agent` is still the previous persona here.)
const inheritable = gatesWorkspace(agent) && !tempWorkspace ? workspace : null;
if (target) { if (target) {
// Code falls back to a recent folder; Cowork resumes its scratch (target.workspace) or // Code falls back to a recent folder; Cowork resumes its scratch (target.workspace) or
@@ -1174,7 +1325,7 @@ export function App() {
const runTaskNow = async (taskId: string, title?: string) => { const runTaskNow = async (taskId: string, title?: string) => {
const r = await runAutomation(taskId); const r = await runAutomation(taskId);
if (!r || !r.ok) return; if (!r || !r.ok) return;
pendingPromptRef.current = r.prompt; pendingPromptRef.current = { text: r.prompt };
activeRunRef.current = { taskId, runId: r.run_id, sessionId: r.session_id }; activeRunRef.current = { taskId, runId: r.run_id, sessionId: r.session_id };
openRunSession(r.session_id, r.workspace, r.agent, { id: taskId, title: title || "" }); openRunSession(r.session_id, r.workspace, r.agent, { id: taskId, title: title || "" });
}; };
@@ -1193,10 +1344,13 @@ export function App() {
const modelDisplay = const modelDisplay =
modelLabels[model]?.split(" · ")[0] || modelLabels[model]?.split(" · ")[0] ||
(model.includes(":") ? model.split(":").slice(1).join(":") : model); (model.includes(":") ? model.split(":").slice(1).join(":") : model);
// Persona name dropped for this release (owner ask 2026-07-22): personas are hidden, // UX-029: with the coworker picker shipping, the coworker's name is a fixed fact again
// so "Coworker" read as noise. The model (+ project folder) are the real fixed facts. // (it was dropped 2026-07-22 while personas were hidden). For temporary folders the raw
const subtitleParts = [modelDisplay]; // path never shows — "Temporary folder" + the Save as project… affordance instead.
if (isProjectScoped(personaOf(agent)) && workspace) subtitleParts.push(baseName(workspace)); const subtitleParts = [fullPersonaName(personaOf(agent)?.name, agent), modelDisplay];
if (isProjectScoped(personaOf(agent)) && workspace)
subtitleParts.push(tempWorkspace ? "Temporary folder" : baseName(workspace));
const showSaveAsProject = hasHistory && tempWorkspace && isProjectScoped(personaOf(agent));
const activeInfo = sessions.find((s) => s.session_id === sessionId); const activeInfo = sessions.find((s) => s.session_id === sessionId);
const activeTitle = activeInfo?.title || "New session"; const activeTitle = activeInfo?.title || "New session";
@@ -1362,7 +1516,6 @@ export function App() {
onOpenPersona={(id) => { onOpenPersona={(id) => {
openPersona(id, "session"); openPersona(id, "session");
}} }}
onManagePersonas={() => openSettings("personas")}
onOpenScheduled={() => setSurface("scheduled")} onOpenScheduled={() => setSurface("scheduled")}
onOpenAutomation={(id) => { onOpenAutomation={(id) => {
setScheduledOpenId(id); setScheduledOpenId(id);
@@ -1474,6 +1627,19 @@ export function App() {
{hasHistory && ( {hasHistory && (
<span className="title-sub" data-testid="session-subtitle"> <span className="title-sub" data-testid="session-subtitle">
{subtitleParts.join(" · ")} {subtitleParts.join(" · ")}
{showSaveAsProject && (
<>
{" · "}
<button
className="text-accent hover:underline"
data-testid="save-as-project"
onMouseDown={(e) => e.stopPropagation()}
onClick={() => void saveAsProject()}
>
Save as project
</button>
</>
)}
</span> </span>
)} )}
</div> </div>
@@ -1625,6 +1791,21 @@ export function App() {
</div> </div>
)} )}
{/* UX-029: per-session setup (coworker + folder) lives in its own quiet row
above the composer never inside the per-message control row. One-time
pick: the whole row leaves after the first message; its facts move to the
session header. */}
{idle && !sessionId.startsWith("__run__") && (
<SessionSetupRow
personas={personas}
agent={agent}
showFolder={needsWorkspace(agent)}
folderName={workspace && !tempWorkspace ? baseName(workspace) : null}
onPickCoworker={pickCoworker}
onPickFolder={pickDraftFolder}
onManage={() => openSettings("personas")}
/>
)}
<Composer <Composer
mode={mode} mode={mode}
model={model} model={model}
@@ -1707,7 +1888,7 @@ export function App() {
projectScoped={isProjectScoped(personaOf(agent))} projectScoped={isProjectScoped(personaOf(agent))}
workspace={workspace || undefined} workspace={workspace || undefined}
branch={branch} branch={branch}
scratchPrimary={agent === "cowork"} scratchPrimary={agent === "cowork" || tempWorkspace}
openAccessKey={accessKey} openAccessKey={accessKey}
onOpenIntegrations={() => setSurface("integrations")} onOpenIntegrations={() => setSurface("integrations")}
/> />
@@ -1729,6 +1910,16 @@ export function App() {
/> />
)} )}
{/* UX-029: the send-time folder dialog the stashed message flies as soon as a
choice lands; Escape/backdrop restores the draft to the composer. */}
{sendGate && surface === "session" && (
<SendFolderDialog
coworkerName={fullPersonaName(personaOf(agent)?.name, agent)}
onPick={resolveSendFolder}
onTemp={() => void startTempAndSend()}
onCancel={cancelSendGate}
/>
)}
{showGate && surface === "session" && gatesWorkspace(agent) && ( {showGate && surface === "session" && gatesWorkspace(agent) && (
<FolderGate <FolderGate
create={gateCreate} create={gateCreate}
+31
View File
@@ -97,6 +97,37 @@ export async function openWorkspace(
return res.json(); return res.json();
} }
/** UX-029 "Start in a temporary folder": create the conversation's temp dir at send time
* (git-init'd for code-family work). Idempotent. */
export async function createTempWorkspace(
sessionId: string,
git = true,
): Promise<{ ok: boolean; path?: string; git?: boolean; error?: string }> {
const res = await fetch(`${httpBase()}/v1/workspaces/temp`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ session_id: sessionId, git }),
});
return res.json();
}
/** UX-029 "Save as project": move a session's temporary folder to a real location.
* Callers reconnect afterwards so the engine rebinds to the new path. */
export async function saveSessionAsProject(
sessionId: string,
path: string,
): Promise<{ ok: boolean; path?: string; error?: string }> {
const res = await fetch(
`${httpBase()}/v1/sessions/${encodeURIComponent(sessionId)}/save-as-project`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ path }),
},
);
return res.json();
}
export async function getTrustedWorkspaces(): Promise<WorkspaceCommandTrust[]> { export async function getTrustedWorkspaces(): Promise<WorkspaceCommandTrust[]> {
const res = await fetch(`${httpBase()}/v1/workspaces/trusted`); const res = await fetch(`${httpBase()}/v1/workspaces/trusted`);
return (await res.json()).workspaces ?? []; return (await res.json()).workspaces ?? [];
@@ -216,8 +216,12 @@ export function AccessSection({
: names.length <= 2 : names.length <= 2
? names.join(", ") ? names.join(", ")
: `${names.slice(0, 2).join(", ")} +${names.length - 2}`; : `${names.slice(0, 2).join(", ")} +${names.length - 2}`;
// A temporary dir's raw name (the session id) never shows — say "Temporary folder"; a
// draft with no folder picked yet shows none at all (UX-029).
const folderPart = projectScoped const folderPart = projectScoped
? baseName(workspace || roots.find((r) => r.primary)?.path || "") || null ? scratchPrimary
? "Temporary folder"
: baseName(workspace || "") || null
: roots.length > 0 : roots.length > 0
? `${roots.length} folder${roots.length === 1 ? "" : "s"}` ? `${roots.length} folder${roots.length === 1 ? "" : "s"}`
: null; : null;
+6 -6
View File
@@ -198,7 +198,7 @@ export function GalleryModal({
)} )}
<div className="text-[11px] uppercase tracking-[0.05em] text-faint font-semibold mb-2"> <div className="text-[11px] uppercase tracking-[0.05em] text-faint font-semibold mb-2">
All personas All coworkers
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{visible.map((p) => { {visible.map((p) => {
@@ -242,15 +242,15 @@ export function GalleryModal({
{source === "team" {source === "team"
? "Nothing shared with your team yet." ? "Nothing shared with your team yet."
: q : q
? "No personas match your search." ? "No coworkers match your search."
: "No personas published yet."} : "No coworkers published yet."}
</div> </div>
)} )}
</div> </div>
{source !== "team" && teamCount === 0 && ( {source !== "team" && teamCount === 0 && (
<div className="mt-5 pt-3 border-t border-line text-[12px] text-faint" data-testid="gallery-team-teaser"> <div className="mt-5 pt-3 border-t border-line text-[12px] text-faint" data-testid="gallery-team-teaser">
From your team nothing shared yet. Publishing a persona to your teammates is coming soon. From your team nothing shared yet. Publishing a coworker to your teammates is coming soon.
</div> </div>
)} )}
</div> </div>
@@ -372,7 +372,7 @@ export function GalleryModal({
<div className="absolute left-1/2 top-[6vh] -translate-x-1/2 w-[720px] max-w-[94vw] max-h-[88vh] rounded-xl2 border border-line bg-panel shadow-2xl overflow-hidden flex flex-col"> <div className="absolute left-1/2 top-[6vh] -translate-x-1/2 w-[720px] max-w-[94vw] max-h-[88vh] rounded-xl2 border border-line bg-panel shadow-2xl overflow-hidden flex flex-col">
<div className="px-5 pt-4 pb-3 border-b border-line flex items-center gap-3 shrink-0"> <div className="px-5 pt-4 pb-3 border-b border-line flex items-center gap-3 shrink-0">
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="text-[15px] font-semibold">Persona Gallery</div> <div className="text-[15px] font-semibold">Coworker Gallery</div>
<div className="text-[12px] text-muted"> <div className="text-[12px] text-muted">
Curated coworkers · installs stay disabled until you approve them Curated coworkers · installs stay disabled until you approve them
</div> </div>
@@ -381,7 +381,7 @@ export function GalleryModal({
<input <input
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
placeholder="Search personas" placeholder="Search coworkers"
className="w-[180px] px-3 py-1.5 rounded-lg border border-line bg-paper text-[12.5px] text-ink outline-none focus:border-accent" className="w-[180px] px-3 py-1.5 rounded-lg border border-line bg-paper text-[12.5px] text-ink outline-none focus:border-accent"
/> />
)} )}
+4 -4
View File
@@ -51,7 +51,7 @@ export function PersonaView({
setError(null); setError(null);
getPersonaDetail(personaId) getPersonaDetail(personaId)
.then((d) => live && setDetail(d)) .then((d) => live && setDetail(d))
.catch(() => live && setError("Could not load this persona.")); .catch(() => live && setError("Could not load this coworker."));
getConnectors() getConnectors()
.then((list) => live && setByName(indexConnectors(list))) .then((list) => live && setByName(indexConnectors(list)))
.catch(() => {}); .catch(() => {});
@@ -88,7 +88,7 @@ export function PersonaView({
<span className="text-faint">·</span> <span className="text-faint">·</span>
</> </>
)} )}
<span className="text-[13px] font-semibold">Persona</span> <span className="text-[13px] font-semibold">Coworker</span>
</div> </div>
); );
@@ -119,7 +119,7 @@ export function PersonaView({
</div> </div>
<div className="ml-auto flex items-center gap-2"> <div className="ml-auto flex items-center gap-2">
<span className="text-[12px] text-muted">{detail.enabled ? "Enabled" : "Disabled"}</span> <span className="text-[12px] text-muted">{detail.enabled ? "Enabled" : "Disabled"}</span>
<Toggle checked={detail.enabled} onChange={toggleEnabled} title="Enable this persona" /> <Toggle checked={detail.enabled} onChange={toggleEnabled} title="Enable this coworker" />
</div> </div>
</header> </header>
@@ -153,7 +153,7 @@ export function PersonaView({
<section> <section>
<div className={`${SEC_H} mb-1`}>Connections for full benefit</div> <div className={`${SEC_H} mb-1`}>Connections for full benefit</div>
<p className="text-[12.5px] text-muted mb-2.5"> <p className="text-[12.5px] text-muted mb-2.5">
Declared by the persona wire {shortPersonaName(detail.name, personaId)} into these Declared by the coworker wire {shortPersonaName(detail.name, personaId)} into these
to unlock its full workflow. to unlock its full workflow.
</p> </p>
<div className="rounded-xl2 border border-line overflow-hidden"> <div className="rounded-xl2 border border-line overflow-hidden">
+6 -6
View File
@@ -90,14 +90,14 @@ export function PersonasTab({ onOpenPersona }: { onOpenPersona?: (id: string) =>
} }
setConsent(r.consent || []); setConsent(r.consent || []);
if (r.personas) setPersonas(r.personas); if (r.personas) setPersonas(r.personas);
setMsg(`Installed ${(r.consent || []).length} persona(s) — review and enable below.`); setMsg(`Installed ${(r.consent || []).length} coworker(s) — review and enable below.`);
setSrc(""); setSrc("");
}; };
return ( return (
<div> <div>
<p className="text-[12.5px] text-muted mb-3 leading-relaxed"> <p className="text-[12.5px] text-muted mb-3 leading-relaxed">
Enable a coworker, then choose whether it appears in the new-session picker. The starred persona Enable a coworker, then choose whether it appears in the coworker picker. The starred coworker
is the default for new sessions. is the default for new sessions.
</p> </p>
@@ -167,7 +167,7 @@ export function PersonasTab({ onOpenPersona }: { onOpenPersona?: (id: string) =>
) : ( ) : (
<button <button
className="text-faint hover:text-danger shrink-0 p-1" className="text-faint hover:text-danger shrink-0 p-1"
title="Delete this persona" title="Delete this coworker"
aria-label={`Delete ${p.name}`} aria-label={`Delete ${p.name}`}
data-testid={`persona-delete-${p.id}`} data-testid={`persona-delete-${p.id}`}
onClick={() => setConfirmDel(p.id)} onClick={() => setConfirmDel(p.id)}
@@ -205,10 +205,10 @@ export function PersonasTab({ onOpenPersona }: { onOpenPersona?: (id: string) =>
))} ))}
</div> </div>
<div className={SEC_H + " mb-1.5"}>Add personas</div> <div className={SEC_H + " mb-1.5"}>Add coworkers</div>
<p className="text-[12px] text-muted mb-3 leading-relaxed"> <p className="text-[12px] text-muted mb-3 leading-relaxed">
Load from a local directory or a public GitHub repo. Files are copied into a managed area (a Load from a local directory or a public GitHub repo. Files are copied into a managed area (a
snapshot), so the persona stays stable even if the source changes. No code runs a persona only snapshot), so the coworker stays stable even if the source changes. No code runs a coworker only
composes vetted tools. composes vetted tools.
</p> </p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -218,7 +218,7 @@ export function PersonasTab({ onOpenPersona }: { onOpenPersona?: (id: string) =>
</select> </select>
<input <input
className={INPUT} className={INPUT}
placeholder={mode === "git" ? "https://github.com/acme/ops-persona" : "/path/to/personas"} placeholder={mode === "git" ? "https://github.com/acme/ops-coworker" : "/path/to/coworkers"}
value={src} value={src}
onChange={(e) => setSrc(e.target.value)} onChange={(e) => setSrc(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && install()} onKeyDown={(e) => e.key === "Enter" && install()}
+2 -2
View File
@@ -4,7 +4,7 @@ import { baseName } from "../paths";
// One directory row, shared by the composer popover and the session start panel. The primary is the // One directory row, shared by the composer popover and the session start panel. The primary is the
// session's bound workspace — the repo/folder for Code/Ops (shown by name), or a throwaway scratch // session's bound workspace — the repo/folder for Code/Ops (shown by name), or a throwaway scratch
// for Cowork (shown as "Temporary space"). It's always read-write and can't be removed. // for Cowork (shown as "Temporary folder"). It's always read-write and can't be removed.
export function RootRow({ export function RootRow({
root, root,
busy, busy,
@@ -23,7 +23,7 @@ export function RootRow({
}) { }) {
const label = root.primary const label = root.primary
? scratchPrimary ? scratchPrimary
? "Temporary space" ? "Temporary folder"
: baseName(root.path) : baseName(root.path)
: root.label; : root.label;
return ( return (
@@ -0,0 +1,104 @@
import { useEffect, useState } from "react";
import { getRecentWorkspaces, openWorkspace, type RecentWorkspace } from "../api";
import { chooseFolder } from "../tauri";
import { baseName } from "../paths";
import { Icon } from "./Icon";
// UX-029: folder enforcement AT SEND, not at session start. A code-family coworker with no
// folder picked gets this dialog when the user hits send; the message goes out the moment a
// choice lands (recents / native picker / temporary folder). Escape restores the draft.
interface Props {
coworkerName: string;
onPick: (path: string, branch?: string | null) => void;
onTemp: () => void;
onCancel: () => void;
}
export function SendFolderDialog({ coworkerName, onPick, onTemp, onCancel }: Props) {
const [recents, setRecents] = useState<RecentWorkspace[]>([]);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
useEffect(() => {
getRecentWorkspaces().then(setRecents).catch(() => {});
}, []);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onCancel();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onCancel]);
const pick = async (path: string) => {
setError("");
const res = await openWorkspace(path);
if (res.ok) onPick(res.path, res.git_branch);
else setError(res.error || "could not open that folder");
};
const browse = async () => {
const picked = await chooseFolder();
if (picked) await pick(picked);
};
return (
<div className="gate-overlay" onClick={onCancel}>
<div
className="w-[410px] bg-panel border border-line rounded-xl2 shadow-2xl p-[18px]"
data-testid="send-folder-dialog"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-[14.5px] font-semibold text-ink mb-1">
Where should {coworkerName} work?
</h3>
<p className="text-[12.5px] text-muted mb-3">
Code work happens inside a folder pick your project, or start somewhere temporary.
</p>
{recents
.filter((w) => w.exists)
.slice(0, 4)
.map((w) => (
<button
key={w.path}
className="w-full flex items-center gap-2.5 px-2.5 py-2 mb-1.5 rounded-lg border border-line hover:border-lineStrong hover:bg-paper text-left"
onClick={() => void pick(w.path)}
title={w.path}
>
<Icon name="folder" size={13} className="shrink-0 text-muted" />
<span className="text-[12.5px] text-ink truncate">{baseName(w.path)}</span>
<span className="ml-auto text-[11.5px] text-faint truncate max-w-[45%]">{w.path}</span>
</button>
))}
<div className="flex gap-2 mt-3">
<button
className="flex-1 text-center text-[12.5px] px-2.5 py-2 rounded-lg border border-lineStrong text-ink hover:bg-paper"
onClick={() => void browse()}
disabled={busy}
>
Choose a folder
</button>
<button
className="flex-1 text-center text-[12.5px] px-2.5 py-2 rounded-lg bg-accent text-white font-semibold hover:opacity-95"
data-testid="start-temp-folder"
onClick={() => {
if (busy) return;
setBusy(true);
onTemp();
}}
disabled={busy}
>
Start in a temporary folder
</button>
</div>
{error && <div className="mt-2 text-[11.5px] text-warnInk">{error}</div>}
<p className="text-[11px] text-faint mt-2.5">
A temporary folder is created only when you send, with git ready you can save it as a
project later. Your message sends as soon as you choose.
</p>
</div>
</div>
);
}
@@ -0,0 +1,148 @@
import { useState } from "react";
import { getRecentWorkspaces, openWorkspace, type Persona, type RecentWorkspace } from "../api";
import { chooseFolder } from "../tauri";
import { fullPersonaName } from "../personaScope";
import { baseName } from "../paths";
import { Icon } from "./Icon";
// UX-029: the session-setup row — per-SESSION choices (coworker + folder) in their own
// quiet chip row above the composer, a different species from the per-MESSAGE controls
// inside it. Rendered only before the first message; after that the whole row leaves and
// its facts move to the session header. Chips are borderless (position, not a border,
// marks them as different) and the coworker chip carries no icon — both owner calls.
interface Props {
personas: Persona[] | null;
agent: string;
// The folder chip renders only for personas that work in a folder (Chat hides it).
showFolder: boolean;
// The user's explicit folder pick for this draft, if any (never a temporary dir's path).
folderName: string | null;
onPickCoworker: (id: string) => void;
onPickFolder: (path: string, branch?: string | null) => void;
onManage: () => void;
}
export function SessionSetupRow(props: Props) {
const [openMenu, setOpenMenu] = useState<"coworker" | "folder" | null>(null);
const [recents, setRecents] = useState<RecentWorkspace[] | null>(null);
const [error, setError] = useState("");
const personas = (props.personas || []).filter((p) => p.enabled);
const current = personas.find((p) => p.id === props.agent);
const toggle = (menu: "coworker" | "folder") => {
setError("");
if (menu === "folder" && openMenu !== "folder") {
getRecentWorkspaces().then(setRecents).catch(() => setRecents([]));
}
setOpenMenu((cur) => (cur === menu ? null : menu));
};
const pickFolder = async (path: string) => {
const res = await openWorkspace(path);
if (!res.ok) {
setError(res.error || "could not open that folder");
return;
}
setOpenMenu(null);
props.onPickFolder(res.path, res.git_branch);
};
const browse = async () => {
const picked = await chooseFolder();
if (picked) await pickFolder(picked);
};
const chip =
"relative inline-flex items-center gap-1.5 px-2 py-1.5 rounded-lg text-[12.5px] text-muted hover:text-ink hover:bg-paper cursor-pointer select-none whitespace-nowrap";
return (
<div className="max-w-3xl mx-auto mb-1.5 px-1 flex items-center gap-1.5" data-testid="setup-row">
{openMenu && <div className="fixed inset-0 z-20" onClick={() => setOpenMenu(null)} />}
{/* Coworker chip — name only, no icon (owner call). */}
<div className="relative">
<button className={chip} data-testid="coworker-chip" onClick={() => toggle("coworker")}>
{fullPersonaName(current?.name, props.agent)}
<Icon name="chevronDown" size={12} className="text-faint" />
</button>
{openMenu === "coworker" && (
<div className="setup-menu absolute bottom-full mb-1.5 left-0 z-30 w-[320px] bg-panel border border-line rounded-xl2 shadow-xl p-1">
{personas.map((p) => (
<button
key={p.id}
className={
"w-full text-left px-2.5 py-2 rounded-lg hover:bg-paper " +
(p.id === props.agent ? "bg-accentSoft/50" : "")
}
onClick={() => {
setOpenMenu(null);
props.onPickCoworker(p.id);
}}
>
<span className="block text-[13px] font-medium text-ink">
{fullPersonaName(p.name, p.id)}
</span>
{p.tagline && (
<span className="block text-[11.5px] text-muted truncate">{p.tagline}</span>
)}
</button>
))}
<div className="border-t border-line mt-1 pt-1">
<button
className="w-full text-left px-2.5 py-1.5 rounded-lg hover:bg-paper text-[12px] text-accent"
onClick={() => {
setOpenMenu(null);
props.onManage();
}}
>
Manage coworkers
</button>
</div>
</div>
)}
</div>
{/* Folder chip — only for personas that work in a folder. */}
{props.showFolder && (
<div className="relative">
<button className={chip} data-testid="folder-chip" onClick={() => toggle("folder")}>
<Icon name="folder" size={13} />
<span className="max-w-[220px] truncate">{props.folderName || "Choose folder"}</span>
<Icon name="chevronDown" size={12} className="text-faint" />
</button>
{openMenu === "folder" && (
<div className="setup-menu absolute bottom-full mb-1.5 left-0 z-30 w-[280px] bg-panel border border-line rounded-xl2 shadow-xl p-1">
{(recents || [])
.filter((w) => w.exists)
.slice(0, 5)
.map((w) => (
<button
key={w.path}
className="w-full text-left flex items-start gap-2.5 px-2.5 py-2 rounded-lg hover:bg-paper"
onClick={() => void pickFolder(w.path)}
title={w.path}
>
<Icon name="folder" size={13} className="mt-0.5 shrink-0 text-muted" />
<span className="min-w-0">
<span className="block text-[13px] font-medium text-ink truncate">{baseName(w.path)}</span>
<span className="block text-[11.5px] text-faint truncate">{w.path}</span>
</span>
</button>
))}
<div className={(recents || []).some((w) => w.exists) ? "border-t border-line mt-1 pt-1" : ""}>
<button
className="w-full text-left px-2.5 py-1.5 rounded-lg hover:bg-paper text-[12px] text-accent"
onClick={() => void browse()}
>
Choose another folder
</button>
</div>
{error && <div className="px-2.5 py-1 text-[11.5px] text-warnInk">{error}</div>}
</div>
)}
</div>
)}
</div>
);
}
+4 -4
View File
@@ -73,7 +73,7 @@ const SET_TABS: {
{ key: "skills", label: "Skills", icon: "book" }, { key: "skills", label: "Skills", icon: "book" },
{ key: "voice", label: "Voice input", icon: "mic" }, { key: "voice", label: "Voice input", icon: "mic" },
{ key: "memory", label: "Memory", icon: "archive" }, { key: "memory", label: "Memory", icon: "archive" },
{ key: "personas", label: "Personas", icon: "sparkle" }, { key: "personas", label: "Coworkers", icon: "sparkle" },
]; ];
export function SettingsView({ export function SettingsView({
@@ -378,8 +378,8 @@ function PersonasSection({ onOpenPersona }: { onOpenPersona?: (id: string) => vo
return ( return (
<section> <section>
<PanelHead <PanelHead
title="Personas" title="Coworkers"
sub="Which coworkers are enabled and shown in the picker, plus installing new persona bundles." sub="Which coworkers are enabled and shown in the picker, plus installing new coworker bundles."
/> />
<PersonasTab key={galleryBump} onOpenPersona={onOpenPersona} /> <PersonasTab key={galleryBump} onOpenPersona={onOpenPersona} />
<button <button
@@ -389,7 +389,7 @@ function PersonasSection({ onOpenPersona }: { onOpenPersona?: (id: string) => vo
> >
<Icon name="sparkle" size={16} className="text-accent shrink-0" /> <Icon name="sparkle" size={16} className="text-accent shrink-0" />
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block text-[13.5px] font-medium">Browse the Persona Gallery</span> <span className="block text-[13.5px] font-medium">Browse the Coworker Gallery</span>
<span className="block text-[12px] text-muted"> <span className="block text-[12px] text-muted">
Curated coworkers from the OpenWorker team see what each can do before installing. Curated coworkers from the OpenWorker team see what each can do before installing.
</span> </span>
+11 -63
View File
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { Sidebar } from "./Sidebar"; import { Sidebar } from "./Sidebar";
import type { SessionInfo } from "../types"; import type { SessionInfo } from "../types";
@@ -53,7 +53,6 @@ const baseProps = {
onTogglePin: vi.fn(), onTogglePin: vi.fn(),
onManage: vi.fn(), onManage: vi.fn(),
onOpenPersona: vi.fn(), onOpenPersona: vi.fn(),
onManagePersonas: vi.fn(),
onOpenScheduled: vi.fn(), onOpenScheduled: vi.fn(),
onOpenAutomation: vi.fn(), onOpenAutomation: vi.fn(),
onOpenIntegrations: vi.fn(), onOpenIntegrations: vi.fn(),
@@ -72,7 +71,7 @@ afterEach(() => {
}); });
describe("Sidebar group/filter control", () => { describe("Sidebar group/filter control", () => {
it("choosing Persona persists via setNavLayout and switches to the per-persona accordion", async () => { it("choosing Coworker persists via setNavLayout and switches to the per-persona accordion", async () => {
const calls = stubFetch([ const calls = stubFetch([
{ match: "/v1/personas", method: "GET", json: PERSONAS }, { match: "/v1/personas", method: "GET", json: PERSONAS },
{ match: "/v1/settings", method: "GET", json: { nav_layout: "flat" } }, { match: "/v1/settings", method: "GET", json: { nav_layout: "flat" } },
@@ -83,9 +82,9 @@ describe("Sidebar group/filter control", () => {
// personas load drives the surfaces; the RECENT header's group/filter control is always present. // personas load drives the surfaces; the RECENT header's group/filter control is always present.
const control = await screen.findByLabelText("Group and filter conversations"); const control = await screen.findByLabelText("Group and filter conversations");
// Open the popover and choose "Group by → Persona". // Open the popover and choose "Group by → Coworker".
fireEvent.click(control); fireEvent.click(control);
fireEvent.click(await screen.findByText("Persona")); fireEvent.click(await screen.findByText("Coworker"));
// POSTs the new layout pref. // POSTs the new layout pref.
await waitFor(() => { await waitFor(() => {
@@ -198,68 +197,17 @@ describe("From Slack group (§31)", () => {
}); });
}); });
describe("New-session split button", () => { describe("New session button", () => {
it("collapses to a plain button when only one persona is enabled", async () => { it("is a plain button (no \u25be picker \u2014 UX-029 moved the pick to the composer) starting the last-used coworker", async () => {
stubFetch([
{
match: "/v1/personas",
method: "GET",
json: { personas: [PERSONAS.personas[0], PERSONAS.personas[3]] }, // cowork + a disabled one
},
{ match: "/v1/settings", method: "GET", json: { nav_layout: "flat" } },
]);
const { container } = render(<Sidebar {...baseProps} />);
await screen.findByText("incident watch");
// No ▾ — nothing to pick; the primary button starts the sole enabled persona.
await waitFor(() => expect(screen.queryByLabelText("Choose a persona")).toBeNull());
fireEvent.click(container.querySelector(".newsplit-primary")!);
expect(baseProps.onNewSession).toHaveBeenCalledWith("cowork");
});
it("primary starts the last-used persona; the menu lists enabled personas + Manage personas…", async () => {
localStorage.setItem("ocw.flag.personas", "1"); // Manage entry is launch-flagged off
stubFetch([
{ match: "/v1/personas", method: "GET", json: PERSONAS },
{ match: "/v1/settings", method: "GET", json: { nav_layout: "flat" } },
]);
const { container } = render(<Sidebar {...baseProps} />);
await screen.findByLabelText("Group and filter conversations");
// Primary action → a new session with the current (last-used) persona.
fireEvent.click(container.querySelector(".newsplit-primary")!);
expect(baseProps.onNewSession).toHaveBeenCalledWith("cowork");
// ▾ opens the persona menu: enabled personas appear, the disabled one does not, plus a manage entry.
fireEvent.click(screen.getByLabelText("Choose a persona"));
const menu = (await screen.findByText("Start a session as")).closest(".newsplit-menu") as HTMLElement;
const w = within(menu);
expect(w.getByText("Ops")).toBeTruthy();
expect(w.getByText("Code")).toBeTruthy();
expect(w.queryByText("Disabled One")).toBeNull();
expect(w.getByText("Manage personas…")).toBeTruthy();
// Selecting a persona starts a session as that persona.
fireEvent.click(w.getByText("Ops"));
expect(baseProps.onNewSession).toHaveBeenCalledWith("ops");
// "Manage personas…" opens the persona management surface.
fireEvent.click(screen.getByLabelText("Choose a persona"));
fireEvent.click(await screen.findByText("Manage personas…"));
expect(baseProps.onManagePersonas).toHaveBeenCalled();
});
it("hides Manage personas… while the launch flag is off (the default)", async () => {
localStorage.removeItem("ocw.flag.personas");
stubFetch([ stubFetch([
{ match: "/v1/personas", method: "GET", json: PERSONAS }, { match: "/v1/personas", method: "GET", json: PERSONAS },
{ match: "/v1/settings", method: "GET", json: { nav_layout: "flat" } }, { match: "/v1/settings", method: "GET", json: { nav_layout: "flat" } },
]); ]);
render(<Sidebar {...baseProps} />); render(<Sidebar {...baseProps} />);
await screen.findByLabelText("Group and filter conversations"); await screen.findByText("incident watch");
fireEvent.click(screen.getByLabelText("Choose a persona"));
const menu = (await screen.findByText("Start a session as")).closest(".newsplit-menu") as HTMLElement; expect(screen.queryByLabelText("Choose a persona")).toBeNull();
expect(within(menu).getByText("Ops")).toBeTruthy(); fireEvent.click(screen.getByText("New session"));
expect(within(menu).queryByText("Manage personas…")).toBeNull(); expect(baseProps.onNewSession).toHaveBeenCalledWith("cowork");
}); });
}); });
+20 -110
View File
@@ -23,10 +23,9 @@ import type { SessionInfo } from "../types";
import { isProjectScoped, shortPersonaName } from "../personaScope"; import { isProjectScoped, shortPersonaName } from "../personaScope";
import { ConnectorIcon } from "../connectors/ConnectorIcon"; import { ConnectorIcon } from "../connectors/ConnectorIcon";
import { Icon, type IconName } from "./Icon"; import { Icon, type IconName } from "./Icon";
import { PersonaGlyph, personaGlyph } from "./personaIcon"; import { personaGlyph } from "./personaIcon";
import { SearchModal } from "./SearchModal"; import { SearchModal } from "./SearchModal";
import { baseName } from "../paths"; import { baseName } from "../paths";
import { showPersonas } from "../flags";
// Session surfaces shown as accordions, in display order. The surfaced personas drive this list // Session surfaces shown as accordions, in display order. The surfaced personas drive this list
// (so third-party / Ops personas appear); the hardcoded set is the fallback before personas load. // (so third-party / Ops personas appear); the hardcoded set is the fallback before personas load.
@@ -128,9 +127,9 @@ interface Props {
onArchiveSession: (id: string, archived: boolean) => void; onArchiveSession: (id: string, archived: boolean) => void;
onTogglePin: (id: string, pinned: boolean) => void; onTogglePin: (id: string, pinned: boolean) => void;
onManage: () => void; onManage: () => void;
// Grouped-nav gear + New-session menu's "Manage personas…" entry points (§7). // Grouped-nav gear entry point (§7). "Manage coworkers…" moved to the composer's
// setup-row picker (UX-029).
onOpenPersona: (id: string) => void; onOpenPersona: (id: string) => void;
onManagePersonas: () => void;
onOpenScheduled: () => void; onOpenScheduled: () => void;
// Scheduled-band row click: open the Automations surface ON that automation (UX-023). // Scheduled-band row click: open the Automations surface ON that automation (UX-023).
onOpenAutomation: (id: string) => void; onOpenAutomation: (id: string) => void;
@@ -276,12 +275,11 @@ export function Sidebar(props: Props) {
}, []); }, []);
const personaOf = (id: string) => personas?.find((p) => p.id === id); const personaOf = (id: string) => personas?.find((p) => p.id === id);
// Sidebar layout (§7): "grouped" = the per-persona accordion; "flat" = a single ungrouped list // Sidebar layout (§7): "grouped" = the per-coworker accordion; "flat" = a single
// (Pinned + Recent). Read the persisted preference on load; ABSENT falls back by the // ungrouped list (Pinned + Recent). Flat stays the default even with Coworkers shipped
// Personas flag — with personas hidden for launch, a per-persona accordion groups by // (UX-029 flips the flag for the picker, not the nav shape — the flat chronological
// a concept the user can't see, so the default is the flat chronological list // list default is the 2026-07-20 owner call). An explicit stored choice always wins.
// (owner call 2026-07-20). An explicit stored choice always wins. const defaultLayout: "flat" | "grouped" = "flat";
const defaultLayout: "flat" | "grouped" = showPersonas() ? "grouped" : "flat";
const [layout, setLayout] = useState<"flat" | "grouped">(defaultLayout); const [layout, setLayout] = useState<"flat" | "grouped">(defaultLayout);
// Sessions shown per group before "Show more" — Settings ▸ Appearance ▸ Sidebar. // Sessions shown per group before "Show more" — Settings ▸ Appearance ▸ Sidebar.
const [peek, setPeek] = useState(5); const [peek, setPeek] = useState(5);
@@ -728,7 +726,7 @@ export function Sidebar(props: Props) {
<div className="px-2 pt-1 pb-1 text-[10.5px] uppercase tracking-[0.06em] text-faint font-semibold"> <div className="px-2 pt-1 pb-1 text-[10.5px] uppercase tracking-[0.06em] text-faint font-semibold">
Group by Group by
</div> </div>
{([["grouped", "Persona"], ["flat", "Chronological"]] as ["flat" | "grouped", string][]).map( {([["grouped", "Coworker"], ["flat", "Chronological"]] as ["flat" | "grouped", string][]).map(
([key, label]) => ( ([key, label]) => (
<button <button
key={key} key={key}
@@ -1005,13 +1003,17 @@ export function Sidebar(props: Props) {
<div className="brand-wordmark text-[15px]">OpenWorker<span className="beta-tag">BETA</span></div> <div className="brand-wordmark text-[15px]">OpenWorker<span className="beta-tag">BETA</span></div>
</div> </div>
{/* New session: split button — primary starts the last-used persona; ▾ picks a specific one. */} {/* New session: a plain button the coworker pick moved to the composer's setup
<NewSessionSplit row (UX-029), so the old persona menu is gone. Starts the last-used persona;
personas={personas} the setup row re-targets the draft in place. */}
current={props.agent} <div className="px-3 pt-2">
onNew={props.onNewSession} <button
onManage={props.onManagePersonas} className="w-full text-left px-3 py-2 rounded-lg bg-accent text-white text-[13px] font-medium hover:opacity-95 flex items-center gap-2"
/> onClick={() => props.onNewSession(props.agent)}
>
<Icon name="plus" size={15} className="shrink-0" /> New session
</button>
</div>
{/* Search: a borderless nav-style entry (not a boxed input) that opens the command-palette {/* Search: a borderless nav-style entry (not a boxed input) that opens the command-palette
SearchModal over the whole app. Matches the bottom-nav rows to reduce the boxy look. */} SearchModal over the whole app. Matches the bottom-nav rows to reduce the boxy look. */}
@@ -1284,95 +1286,3 @@ export function Sidebar(props: Props) {
</div> </div>
); );
} }
// New-session split button (§8): the primary action starts a session with the last-used persona
// (`current`); the ▾ opens a menu of the enabled personas (from /v1/personas) plus a "Manage
// personas…" entry. A plain custom split control — the pill-shaped Dropdown doesn't fit this shape.
function NewSessionSplit({
personas,
current,
onNew,
onManage,
}: {
personas: Persona[] | null;
current: string;
onNew: (agent: string) => void;
onManage: () => void;
}) {
const [open, setOpen] = useState(false);
const enabled = (personas || []).filter((p) => p.enabled);
// With a single enabled persona there is nothing to pick — the split collapses to a plain
// button (owner ask 2026-07-09). `personas === null` (still loading) keeps the split so the
// control doesn't visibly change shape once the list arrives with 2+.
const solo = personas !== null && enabled.length <= 1;
return (
<div className="px-3 pt-2 relative">
<div className="flex">
<button
className={
"newsplit-primary flex-1 text-left px-3 py-2 bg-accent text-white text-[13px] font-medium hover:opacity-95 flex items-center gap-2 " +
(solo ? "rounded-lg" : "rounded-l-lg")
}
onClick={() => onNew(solo && enabled.length === 1 ? enabled[0].id : current)}
>
<Icon name="plus" size={15} className="shrink-0" /> New session
</button>
{!solo && (
<button
className="px-2.5 rounded-r-lg bg-accent text-white border-l border-white/25 hover:opacity-95 flex items-center"
title="Start with a specific persona"
aria-label="Choose a persona"
onClick={() => setOpen((v) => !v)}
>
<Icon name="chevronDown" size={13} />
</button>
)}
</div>
{open && (
<>
<div className="fixed inset-0 z-20" onClick={() => setOpen(false)} />
<div className="newsplit-menu absolute left-3 right-3 mt-1 z-30 bg-panel border border-line rounded-xl2 shadow-xl p-1">
<div className="px-2 py-1 text-[10.5px] uppercase tracking-[0.06em] text-faint font-semibold">
Start a session as
</div>
{enabled.map((p) => (
<button
key={p.id}
className="w-full flex items-center gap-2.5 px-2 py-1.5 rounded-lg hover:bg-paper text-left"
onClick={() => {
setOpen(false);
onNew(p.id);
}}
>
<span className="w-6 h-6 rounded-md bg-paper border border-line grid place-items-center text-muted shrink-0">
<PersonaGlyph icon={p.icon} family={p.family} size={12} />
</span>
<span className="min-w-0">
<span className="block text-[13px] font-medium truncate">
{shortPersonaName(p.name, p.id)}
</span>
{p.tagline && (
<span className="block text-[11px] text-muted truncate">{p.tagline}</span>
)}
</span>
</button>
))}
{showPersonas() && (
<div className="border-t border-line mt-1 pt-1">
<button
className="w-full px-2 py-1.5 rounded-lg hover:bg-paper text-left text-[12.5px] text-muted"
onClick={() => {
setOpen(false);
onManage();
}}
>
Manage personas
</button>
</div>
)}
</div>
</>
)}
</div>
);
}
+4 -4
View File
@@ -15,7 +15,7 @@ function flag(key: string, fallback: boolean): boolean {
return fallback; return fallback;
} }
/** Personas management is hidden for launch (owner call, 2026-07-19): the Settings tab /** Coworkers shipped with UX-029 (the composer's setup-row picker): the Settings
* and the "Manage personas…" menu entry stay off until the persona catalog is ready. * Coworkers tab and management flows are ON by default. `ocw.flag.personas` = "0" is the
* The e2e suite sets `ocw.flag.personas` to keep the hidden flows covered. */ * escape hatch to hide them again. (Hidden for launch 2026-07-19 enabled 2026-08-10.) */
export const showPersonas = () => flag("ocw.flag.personas", false); export const showPersonas = () => flag("ocw.flag.personas", true);
+2 -2
View File
@@ -65,7 +65,7 @@ def test_persona_detail_endpoint(tmp_path, monkeypatch):
# identity + capabilities (from the manifest/entry) # identity + capabilities (from the manifest/entry)
assert detail["id"] == "ops" assert detail["id"] == "ops"
assert detail["name"] == "Ops Coworker" assert detail["name"] == "Ops Coworker"
assert detail["enabled"] is False # non-default personas ship disabled (opt-in) assert detail["enabled"] is True # builtins ship enabled (UX-029)
assert ( assert (
detail["workspace"] == "deliverable" detail["workspace"] == "deliverable"
) # §16 collapse: ops is a scratch persona now ) # §16 collapse: ops is a scratch persona now
@@ -131,7 +131,7 @@ def test_persona_enable_toggle(tmp_path, monkeypatch):
client = TestClient(create_app(mgr)) client = TestClient(create_app(mgr))
before = {p["id"]: p for p in client.get("/v1/personas").json()["personas"]} before = {p["id"]: p for p in client.get("/v1/personas").json()["personas"]}
assert before["ops"]["enabled"] is False # ships disabled; only cowork starts on assert before["ops"]["enabled"] is True # builtins ship enabled (UX-029)
assert before["cowork"]["enabled"] is True assert before["cowork"]["enabled"] is True
resp = client.post("/v1/personas/ops/enable", json={"enabled": True}).json() resp = client.post("/v1/personas/ops/enable", json={"enabled": True}).json()
+12 -14
View File
@@ -20,29 +20,27 @@ def test_builtins_present(tmp_path):
assert reg.get("code").manifest is None assert reg.get("code").manifest is None
def test_sidebar_defaults_to_cowork_only(tmp_path): def test_sidebar_defaults_to_surfaced_builtins(tmp_path):
reg = _reg(tmp_path) reg = _reg(tmp_path)
sidebar = reg.sidebar() sidebar = reg.sidebar()
ids = [e["name"] for e in sidebar] ids = [e["name"] for e in sidebar]
# A fresh install offers ONLY the default persona (owner call 2026-07-09); # Built-ins ship enabled (UX-029: the coworker picker is their front door); Chat
# everything else is opt-in from Settings ▸ Personas. # stays default-hidden via the surfaced axis. Installed personas remain opt-in.
assert ids == ["cowork"]
assert sidebar[0]["default"] is True
# Enabling adds to the picker (enable implies surface).
reg.set_enabled("code", True)
reg.set_enabled("ops", True)
ids = [e["name"] for e in reg.sidebar()]
assert ids[0] == "cowork" assert ids[0] == "cowork"
assert set(ids) == {"cowork", "code", "ops"} assert set(ids) == {"cowork", "code", "ops"}
assert sidebar[0]["default"] is True
# An explicit disable removes a builtin from the picker.
reg.set_enabled("code", False)
assert "code" not in [e["name"] for e in reg.sidebar()]
def test_chat_disabled_by_default_but_resolvable(tmp_path): def test_chat_hidden_by_default_but_resolvable(tmp_path):
reg = _reg(tmp_path) reg = _reg(tmp_path)
assert reg.is_surfaced("chat") is False # default-hidden assert reg.is_surfaced("chat") is False # default-hidden from the grouped nav
assert reg.is_enabled("chat") is False # opt-in like every non-default persona assert reg.is_enabled("chat") is True # builtins ship enabled (UX-029)
assert reg.agent("chat").name == "chat" # live sessions keep resolving assert reg.agent("chat").name == "chat" # live sessions keep resolving
# The user can enable it from the Personas tab (enable implies surface). # Surfacing it adds it to the sidebar picker too.
reg.set_enabled("chat", True) reg.set_surfaced("chat", True)
assert "chat" in [e["name"] for e in reg.sidebar()] assert "chat" in [e["name"] for e in reg.sidebar()]
+3 -3
View File
@@ -60,10 +60,10 @@ def test_chat_completions_openai_shape(tmp_path):
def test_agents_and_memory_rest(tmp_path): def test_agents_and_memory_rest(tmp_path):
client = _client(tmp_path, []) client = _client(tmp_path, [])
agents = client.get("/v1/agents").json()["agents"] agents = client.get("/v1/agents").json()["agents"]
# The picker lists enabled+surfaced personas — a fresh install is cowork-only # The picker lists enabled+surfaced personas — builtins ship enabled (UX-029);
# (non-default personas ship disabled, opt-in from Settings ▸ Personas). # Chat stays default-hidden via the surfaced axis.
names = [a["name"] for a in agents] names = [a["name"] for a in agents]
assert names == ["cowork"] assert names == ["cowork", "code", "ops"]
assert "skills" in client.get("/v1/skills").json() # catalog (may be empty) assert "skills" in client.get("/v1/skills").json() # catalog (may be empty)
added = client.post("/v1/memory", json={"content": "prefer pathlib"}).json() added = client.post("/v1/memory", json={"content": "prefer pathlib"}).json()
+114
View File
@@ -0,0 +1,114 @@
"""UX-029 — temporary workspaces for code-family sessions.
"Start in a temporary folder": the dir is created at SEND time via POST /v1/workspaces/temp
(git-init'd for code work), flagged in the `ready` event as `temp_workspace`, and can later be
moved to a real location via "Save as project…" (POST /v1/sessions/{id}/save-as-project).
"""
from pathlib import Path
from fastapi.testclient import TestClient
from coworker.providers import ModelCapabilities, ProviderClient
from coworker.server import create_app
from coworker.server.manager import SessionManager
from coworker.sessions import SessionRecord
class ScriptedProvider(ProviderClient):
def __init__(self, turns=None):
self._turns = list(turns or [])
def complete(self, *, model, messages, tools=None, **settings):
return self._turns.pop(0)
def capabilities(self, model):
return ModelCapabilities()
def _mgr(tmp_path, monkeypatch) -> SessionManager:
monkeypatch.setenv("COWORKER_STATE_DIR", str(tmp_path / "state"))
mgr = SessionManager(workspace=tmp_path, provider=ScriptedProvider([]))
mgr._prefs["scratch_base"] = str(tmp_path / "scratch")
return mgr
def test_provision_temp_workspace_creates_dir_with_git(tmp_path, monkeypatch):
mgr = _mgr(tmp_path, monkeypatch)
client = TestClient(create_app(mgr))
res = client.post("/v1/workspaces/temp", json={"session_id": "abc123", "git": True}).json()
assert res["ok"] is True
d = Path(res["path"])
assert d.is_dir()
assert d.parent == (tmp_path / "scratch").resolve()
assert res["git"] is True and (d / ".git").is_dir()
# Idempotent — a re-send against the existing dir is a no-op.
again = client.post("/v1/workspaces/temp", json={"session_id": "abc123"}).json()
assert again["ok"] is True and again["path"] == res["path"]
# It IS a temp workspace, and never appears in the recents (project) list.
assert mgr.is_temp_workspace(res["path"]) is True
mgr.session_store.touch_workspace(res["path"])
assert res["path"] not in [w["path"] for w in mgr.recent_workspaces()]
def test_provision_temp_workspace_rejects_bad_ids(tmp_path, monkeypatch):
mgr = _mgr(tmp_path, monkeypatch)
client = TestClient(create_app(mgr))
for bad in ["", "../evil", "a/b", ".."]:
assert client.post("/v1/workspaces/temp", json={"session_id": bad}).json()["ok"] is False
def test_save_temp_as_project_moves_and_rebinds(tmp_path, monkeypatch):
mgr = _mgr(tmp_path, monkeypatch)
client = TestClient(create_app(mgr))
src = Path(client.post("/v1/workspaces/temp", json={"session_id": "sess1"}).json()["path"])
(src / "notes.txt").write_text("hello", encoding="utf-8")
mgr.session_store.save(
SessionRecord(
session_id="sess1",
workspace=str(src),
model="m",
mode="interactive",
messages=[{"role": "user", "content": "hi"}],
agent="code",
)
)
dest = tmp_path / "projects" / "myproj"
res = client.post("/v1/sessions/sess1/save-as-project", json={"path": str(dest)}).json()
assert res["ok"] is True
moved = Path(res["path"])
assert moved == dest.resolve()
assert (moved / "notes.txt").read_text(encoding="utf-8") == "hello"
assert not src.exists()
assert mgr.session_store.load("sess1").workspace == str(moved)
assert mgr.is_temp_workspace(str(moved)) is False
def test_save_temp_as_project_guards(tmp_path, monkeypatch):
mgr = _mgr(tmp_path, monkeypatch)
client = TestClient(create_app(mgr))
# Not a temp workspace → refused.
real = tmp_path / "realproj"
real.mkdir()
mgr.session_store.save(
SessionRecord(session_id="s2", workspace=str(real), model="m", mode="interactive", agent="code")
)
res = client.post("/v1/sessions/s2/save-as-project", json={"path": str(tmp_path / "x")}).json()
assert res["ok"] is False
# Non-empty destination → refused, source untouched.
src = Path(client.post("/v1/workspaces/temp", json={"session_id": "s3"}).json()["path"])
mgr.session_store.save(
SessionRecord(session_id="s3", workspace=str(src), model="m", mode="interactive", agent="code")
)
full = tmp_path / "full"
full.mkdir()
(full / "occupied.txt").write_text("x", encoding="utf-8")
res = client.post("/v1/sessions/s3/save-as-project", json={"path": str(full)}).json()
assert res["ok"] is False and src.is_dir()