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()