diff --git a/coworker/personas/registry.py b/coworker/personas/registry.py index ae964529..06ccfb2b 100644 --- a/coworker/personas/registry.py +++ b/coworker/personas/registry.py @@ -219,11 +219,15 @@ class PersonaRegistry: return self._entries.get(persona_id) def is_enabled(self, persona_id: str) -> bool: - # No user choice recorded → only the default persona ships enabled (owner call, - # 2026-07-09): a fresh install is Coworker-only, everything else is opt-in from - # Settings ▸ Personas. Explicit state (either way) always wins. + # Explicit state (either way) always wins. Absent a user choice, BUILT-IN personas + # ship enabled — the composer picker is their front door (UX-029, supersedes the + # 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: 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 def is_surfaced(self, persona_id: str) -> bool: diff --git a/coworker/server/app.py b/coworker/server/app.py index e8b97638..80dd95bc 100644 --- a/coworker/server/app.py +++ b/coworker/server/app.py @@ -641,6 +641,21 @@ def create_app(manager: SessionManager) -> FastAPI: 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") async def pick_workspace() -> dict[str, Any]: # 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) 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( str(getattr(engine, "audit_context", {}).get("workspace", "")) ), diff --git a/coworker/server/manager.py b/coworker/server/manager.py index 66a51ecc..ab24d2e9 100644 --- a/coworker/server/manager.py +++ b/coworker/server/manager.py @@ -361,6 +361,75 @@ class SessionManager: d.mkdir(parents=True, exist_ok=True) 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]: if requested: p = Path(requested).expanduser() diff --git a/surfaces/gui/e2e/access-section.spec.ts b/surfaces/gui/e2e/access-section.spec.ts index 5d4b490e..24f0bbaf 100644 --- a/surfaces/gui/e2e/access-section.spec.ts +++ b/surfaces/gui/e2e/access-section.spec.ts @@ -30,7 +30,7 @@ test("no topbar opener; the Access header IS the ambient glance; expanding edits await expect(body.getByText("Sources")).toBeVisible(); await expect(body.getByText("Slack", { exact: true })).toBeVisible(); await expect(body.getByText("email context for morning summaries")).toBeVisible(); - await expect(body.getByTestId("drawer-directories").getByText("Temporary space")).toBeVisible(); + await expect(body.getByTestId("drawer-directories").getByText("Temporary folder")).toBeVisible(); await expect(page.getByRole("dialog")).toHaveCount(0); // Channels is a chat capability, not a two_way one: Slack gets the drill-down, GitHub diff --git a/surfaces/gui/e2e/family-gate.spec.ts b/surfaces/gui/e2e/family-gate.spec.ts index 2e242bea..0b6a2088 100644 --- a/surfaces/gui/e2e/family-gate.spec.ts +++ b/surfaces/gui/e2e/family-gate.spec.ts @@ -1,43 +1,93 @@ import { test, expect } from "./fixtures"; -// §16 workspace collapse: the persona FAMILY alone decides the workspace behavior. -// code → an explicit project folder, enforced by the FolderGate (no chat-behind-it escape) -// knowledge → starts orphan on a transparent scratch dir — never gated -// (The mock's Ops persona is knowledge-family with zero sessions, so picking it exercises the -// brand-new-session path, not a resume.) +// UX-029: the persona FAMILY still decides workspace behavior, but code-family +// enforcement moved from a modal gate at session start to the SEND moment: +// code → send with no folder → "Where should … work?" dialog (recents / native +// picker / "Start in a temporary folder", git-init'd, created only now) +// knowledge → starts orphan on a transparent temporary dir — never gated +// The coworker pick lives in the setup chip row above the composer, only before the +// first message of a new session; afterwards the row leaves and the facts move to the +// session header. -const personaMenu = (page: import("@playwright/test").Page) => page.locator(".newsplit-menu"); - -async function startAs(page: import("@playwright/test").Page, persona: RegExp) { - await page.getByLabel("Choose a persona").click(); - await personaMenu(page).getByRole("button", { name: persona }).click(); +async function newDraftAs(page: import("@playwright/test").Page, coworker: RegExp) { + await page.getByText("New session").first().click(); + await page.getByTestId("coworker-chip").click(); + await page.locator(".setup-menu").getByRole("button", { name: coworker }).click(); } -test("knowledge persona: new session starts instantly, no folder gate", async ({ page }) => { +test("knowledge coworker: new session starts instantly, no gate, no dialog", async ({ page }) => { await page.goto("/"); - await expect(page.getByPlaceholder(/Ask the coworker/)).toBeVisible(); + await newDraftAs(page, /Ops Coworker/); - await startAs(page, /Ops/); await expect(page.locator(".gate-overlay")).toHaveCount(0); - await expect(page.getByPlaceholder(/Ask the coworker/)).toBeVisible(); + const box = page.getByPlaceholder(/Ask the coworker/); + await box.fill("hello there"); + await page.getByRole("button", { name: "Send" }).click(); + await expect(page.getByText(/Echo: hello there/)).toBeVisible(); + await expect(page.getByTestId("send-folder-dialog")).toHaveCount(0); }); -test("code persona: the folder gate blocks until a project is chosen", async ({ page }) => { +test("code coworker: send with no folder asks where to work; temp folder sends the message", async ({ + page, +}) => { await page.goto("/"); - await expect(page.getByPlaceholder(/Ask the coworker/)).toBeVisible(); + await newDraftAs(page, /Code Coworker/); - await startAs(page, /Code/); - - const gate = page.locator(".gate-overlay"); - await expect(gate).toBeVisible(); - await expect(gate.getByText("Choose a project folder")).toBeVisible(); - // No escape hatch: the gate offers pick-a-folder only (no "switch to Chat" — owner call, §16). - await expect(gate.getByText(/chat/i)).toHaveCount(0); - - await gate.getByPlaceholder("/path/to/your/project").fill("/tmp/e2e-project"); - await gate.getByRole("button", { name: "Open", exact: true }).click(); - - // Gate clears, the session is rooted in the chosen folder, and the code composer is live. + // No modal gate up front — the composer is live and the draft is composable. await expect(page.locator(".gate-overlay")).toHaveCount(0); - await expect(page.getByPlaceholder(/Ask the coder/)).toBeVisible(); + await page.getByPlaceholder(/Ask the coder/).fill("fix the tests"); + await page.getByRole("button", { name: "Send" }).click(); + + const dlg = page.getByTestId("send-folder-dialog"); + await expect(dlg).toBeVisible(); + await expect(dlg.getByText("Where should Code Coworker work?")).toBeVisible(); + await dlg.getByTestId("start-temp-folder").click(); + + // The message flies as soon as the choice lands — no second send click, and the local + // echo isn't duplicated by turn_start (the notice sits between them). + await expect(page.getByText(/Echo: fix the tests/)).toBeVisible(); + await expect(page.locator(".main-scroll").getByText("fix the tests", { exact: true })).toHaveCount(1); + await expect(page.getByText("Temporary folder created · git initialized")).toBeVisible(); + + // The raw temp path never shows: header says "Temporary folder" + Save as project…. + const sub = page.getByTestId("session-subtitle"); + await expect(sub).toContainText("Code Coworker"); + await expect(sub).toContainText("Temporary folder"); + await expect(sub).not.toContainText("ow-temp"); + await expect(page.getByTestId("save-as-project")).toBeVisible(); + + // One-time pick: the setup row left with the first message. + await expect(page.getByTestId("setup-row")).toHaveCount(0); + + // A NEW session never inherits the temporary dir — the folder chip starts fresh. + await page.getByText("New session").first().click(); + await expect(page.getByTestId("folder-chip")).toContainText("Choose folder"); +}); + +test("code coworker: Choose a folder… binds the picked project and sends", async ({ page }) => { + await page.goto("/"); + await newDraftAs(page, /Code Coworker/); + + await page.getByPlaceholder(/Ask the coder/).fill("hello repo"); + await page.getByRole("button", { name: "Send" }).click(); + + // Native pick is mocked server-side → /tmp/picked-folder. + await page.getByTestId("send-folder-dialog").getByRole("button", { name: "Choose a folder…" }).click(); + await expect(page.getByText(/Echo: hello repo/)).toBeVisible(); + await expect(page.getByTestId("session-subtitle")).toContainText("picked-folder"); + await expect(page.getByTestId("save-as-project")).toHaveCount(0); +}); + +test("escape restores the draft instead of losing it", async ({ page }) => { + await page.goto("/"); + await newDraftAs(page, /Code Coworker/); + + const box = page.getByPlaceholder(/Ask the coder/); + await box.fill("precious draft"); + await page.getByRole("button", { name: "Send" }).click(); + await expect(page.getByTestId("send-folder-dialog")).toBeVisible(); + + await page.keyboard.press("Escape"); + await expect(page.getByTestId("send-folder-dialog")).toHaveCount(0); + await expect(box).toHaveValue("precious draft"); }); diff --git a/surfaces/gui/e2e/fixtures.ts b/surfaces/gui/e2e/fixtures.ts index e6dfa409..48b3e5c7 100644 --- a/surfaces/gui/e2e/fixtures.ts +++ b/surfaces/gui/e2e/fixtures.ts @@ -947,6 +947,15 @@ export async function mockApi(page: import("@playwright/test").Page) { const b = req.postDataJSON(); return json({ ok: true, path: b.path, git_branch: "main" }); } + if (p.endsWith("/v1/workspaces/temp") && m === "POST") { + // UX-029: "Start in a temporary folder" — created at send time, git-ready. + const b = req.postDataJSON(); + return json({ ok: true, path: `/tmp/ow-temp/${b.session_id}`, git: b.git !== false }); + } + if (/\/v1\/sessions\/[^/]+\/save-as-project$/.test(p) && m === "POST") { + const b = req.postDataJSON(); + return json({ ok: true, path: b.path }); + } // must precede the /v1/personas/{id} catch-all (install matches it too) if (p.endsWith("/v1/personas/install") && m === "POST") { const b = req.postDataJSON(); diff --git a/surfaces/gui/e2e/gallery.spec.ts b/surfaces/gui/e2e/gallery.spec.ts index e33ce629..d72f3900 100644 --- a/surfaces/gui/e2e/gallery.spec.ts +++ b/surfaces/gui/e2e/gallery.spec.ts @@ -6,12 +6,10 @@ import { expect } from "@playwright/test"; import { test } from "./fixtures"; async function openPersonas(page) { - // Personas is launch-flagged off by default — these suites cover the flagged-on flows. - await page.addInitScript(() => localStorage.setItem("ocw.flag.personas", "1")); await page.goto("/"); await page.getByTestId("account-row").click(); await page.getByRole("button", { name: "Settings", exact: true }).click(); - await page.getByRole("button", { name: "Personas", exact: true }).click(); + await page.getByRole("button", { name: "Coworkers", exact: true }).click(); await expect(page.getByTestId("gallery-link")).toBeVisible(); } @@ -65,9 +63,9 @@ test("signed in: featured carousel + list; solo page installs informed; Done ret await expect(page.getByTestId("gallery-team-teaser")).toContainText("coming soon"); // Search narrows the list. - await page.getByPlaceholder("Search personas").fill("recruit"); + await page.getByPlaceholder("Search coworkers").fill("recruit"); await expect(page.getByTestId("gallery-sales")).not.toBeVisible(); - await page.getByPlaceholder("Search personas").fill(""); + await page.getByPlaceholder("Search coworkers").fill(""); // Solo page: pitch + manifest-derived capabilities BEFORE install. await page.getByTestId("gallery-sales").click(); diff --git a/surfaces/gui/e2e/persona-surfacing.spec.ts b/surfaces/gui/e2e/persona-surfacing.spec.ts index 848f3bc9..ec8aaf7a 100644 --- a/surfaces/gui/e2e/persona-surfacing.spec.ts +++ b/surfaces/gui/e2e/persona-surfacing.spec.ts @@ -1,10 +1,5 @@ import { test, expect } from "./fixtures"; -// Personas is launch-flagged off by default — this suite covers the flagged-on flows. -test.beforeEach(async ({ page }) => { - await page.addInitScript(() => localStorage.setItem("ocw.flag.personas", "1")); -}); - // Regression for the invisible-after-install bug (2026-07-03): enabling a persona in // Settings ▸ Personas must surface it EVERYWHERE without a reload — the New-Session picker and // the grouped sidebar — via the PERSONAS_CHANGED event (and backend enable-implies-surface). @@ -15,18 +10,19 @@ test("enabling an installed persona surfaces it in picker + sidebar without relo await page.goto("/"); const sidebar = page.locator(".sidebar"); - // Disabled install: absent from the persona picker and the grouped sidebar. - await page.getByLabel("Choose a persona").click(); - const menu = page.locator(".newsplit-menu"); + // Disabled install: absent from the composer's coworker picker and the grouped sidebar. + await page.getByText("New session").first().click(); + await page.getByTestId("coworker-chip").click(); + const menu = page.locator(".setup-menu"); await expect(menu).toBeVisible(); await expect(menu.getByText("Acme Notes")).toHaveCount(0); - await page.locator(".fixed.inset-0.z-20").click(); // close via backdrop + await page.locator(".fixed.inset-0.z-20").click({ position: { x: 5, y: 5 } }); // close via backdrop (menu sits over center) await expect(sidebar.getByText("Acme Notes")).toHaveCount(0); - // Enable it on the Personas page. + // Enable it on the Coworkers page. await page.getByTestId("account-row").click(); await page.getByRole("button", { name: "Settings", exact: true }).click(); - await page.getByRole("button", { name: "Personas", exact: true }).click(); + await page.getByRole("button", { name: "Coworkers", exact: true }).click(); const row = page.locator(".divide-y > div").filter({ hasText: "Acme Notes" }); // Controlled checkbox: the DOM state flips only after the POST round-trip, so click + expect // (a plain .check() asserts the state synchronously and fails). @@ -36,8 +32,9 @@ test("enabling an installed persona surfaces it in picker + sidebar without relo // No reload: the sidebar group and the picker both pick it up via PERSONAS_CHANGED. await expect(sidebar.getByText("Acme Notes")).toBeVisible(); - await page.getByLabel("Choose a persona").click(); - await expect(page.locator(".newsplit-menu").getByText("Acme Notes")).toBeVisible(); + await page.getByText("New session").first().click(); + await page.getByTestId("coworker-chip").click(); + await expect(page.locator(".setup-menu").getByText("Acme Notes")).toBeVisible(); }); // Disable-archives (§18): disabling a persona archives its conversations, so the confirm must @@ -52,7 +49,7 @@ test("disabling a persona with conversations asks first, then archives them", as await page.getByTestId("account-row").click(); await page.getByRole("button", { name: "Settings", exact: true }).click(); - await page.getByRole("button", { name: "Personas", exact: true }).click(); + await page.getByRole("button", { name: "Coworkers", exact: true }).click(); const row = page.locator(".divide-y > div").filter({ hasText: "Ops Coworker" }); const enabled = row.getByRole("checkbox", { name: "Enabled" }); @@ -78,7 +75,7 @@ test("disabling a persona with no conversations skips the confirm", async ({ pag await page.goto("/"); await page.getByTestId("account-row").click(); await page.getByRole("button", { name: "Settings", exact: true }).click(); - await page.getByRole("button", { name: "Personas", exact: true }).click(); + await page.getByRole("button", { name: "Coworkers", exact: true }).click(); const row = page.locator(".divide-y > div").filter({ hasText: "Code" }); const enabled = row.getByRole("checkbox", { name: "Enabled" }); await enabled.click(); diff --git a/surfaces/gui/e2e/roots.spec.ts b/surfaces/gui/e2e/roots.spec.ts index 6652785f..ee9ad25b 100644 --- a/surfaces/gui/e2e/roots.spec.ts +++ b/surfaces/gui/e2e/roots.spec.ts @@ -14,8 +14,8 @@ test("working directories: add folders with the read-only / read-write gate", as const dirs = page.getByTestId("drawer-directories"); await expect(dirs.getByText("Folders")).toBeVisible(); - // The primary is the writable scratch workspace (Cowork shows it as "Temporary space"). - await expect(dirs.getByText("Temporary space")).toBeVisible(); + // The primary is the writable scratch workspace (Cowork shows it as "Temporary folder"). + await expect(dirs.getByText("Temporary folder")).toBeVisible(); // Add a folder — the gate defaults to read-only (Allow writes OFF). The Browse button works // in the BROWSER too (sidecar-opened native picker; owner report 2026-07-04). diff --git a/surfaces/gui/e2e/session-shell.spec.ts b/surfaces/gui/e2e/session-shell.spec.ts index 0156c87a..0e918f06 100644 --- a/surfaces/gui/e2e/session-shell.spec.ts +++ b/surfaces/gui/e2e/session-shell.spec.ts @@ -33,7 +33,7 @@ test("top-left cluster renders only while the sidebar is collapsed", async ({ pa await expect(page.getByTestId("topbar-cluster")).toHaveCount(0); }); -test("facts subtitle: absent on a fresh session, model-only after the first turn, inert", async ({ +test("facts subtitle: absent on a fresh session, coworker + model after the first turn, inert", async ({ page, }) => { await page.goto("/"); @@ -51,10 +51,10 @@ test("facts subtitle: absent on a fresh session, model-only after the first turn await page.getByRole("button", { name: "Send" }).click(); await expect(page.getByText(/Echo: hello/)).toBeVisible(); - // Model only — no persona name (owner ask 2026-07-22: personas are hidden this release), - // and the subtitle is a plain fact line, not a button to the persona page. + // Coworker + model (UX-029 restored the coworker name — the picker shipped), and the + // subtitle is a plain fact line, not a button to the persona page. const sub = page.getByTestId("session-subtitle"); - await expect(sub).toHaveText("Claude Opus 4.8"); + await expect(sub).toHaveText("Coworker · Claude Opus 4.8"); await expect(page.locator(".dd").filter({ hasText: "Claude Opus 4.8" })).toBeVisible(); await sub.click(); await expect(page.getByRole("button", { name: "Back", exact: true })).toHaveCount(0); diff --git a/surfaces/gui/e2e/settings.spec.ts b/surfaces/gui/e2e/settings.spec.ts index 1bee81fa..82f791be 100644 --- a/surfaces/gui/e2e/settings.spec.ts +++ b/surfaces/gui/e2e/settings.spec.ts @@ -2,7 +2,7 @@ import { test, expect } from "./fixtures"; // Guards the Settings-as-page refactor (§13, IA per UX-021): the ⚙ menu opens a full-page // surface with a left sub-nav — General · Models · Voice input — and each section renders. -// Files is a card inside General; Personas is launch-flagged off. +// Files is a card inside General; Coworkers ships on (flag "0" hides it). test("Settings opens as a full page and navigates sections", async ({ page }) => { await page.goto("/"); @@ -15,9 +15,9 @@ test("Settings opens as a full page and navigates sections", async ({ page }) => for (const label of ["General", "Models", "Voice input"]) { await expect(page.getByRole("button", { name: label, exact: true })).toBeVisible(); } - // Folded/hidden tabs: Files is a General card now; Personas is launch-flagged off. + // Folded tabs: Files is a General card now; Coworkers ships as its own tab (UX-029). await expect(page.getByRole("button", { name: "Files", exact: true })).toHaveCount(0); - await expect(page.getByRole("button", { name: "Personas", exact: true })).toHaveCount(0); + await expect(page.getByRole("button", { name: "Coworkers", exact: true })).toBeVisible(); // The Files card lives inside General. await expect(page.getByText("Each conversation gets its own folder")).toBeVisible(); @@ -26,14 +26,22 @@ test("Settings opens as a full page and navigates sections", async ({ page }) => await expect(page.getByTestId("set-provider-openai")).toBeVisible(); }); -// The launch flag brings the Personas tab back (the gallery/persona suites rely on it). -test("Settings: Personas tab returns behind the launch flag", async ({ page }) => { - await page.addInitScript(() => localStorage.setItem("ocw.flag.personas", "1")); +// The flag's "0" escape hatch hides the tab again (the default is on — UX-029). +test("Settings: Coworkers tab opens by default; flag \"0\" hides it", async ({ page }) => { await page.goto("/"); await page.getByTestId("account-row").click(); await page.getByRole("button", { name: "Settings", exact: true }).click(); - await page.getByRole("button", { name: "Personas", exact: true }).click(); - await expect(page.getByText("Add personas")).toBeVisible(); + await page.getByRole("button", { name: "Coworkers", exact: true }).click(); + await expect(page.getByText("Add coworkers")).toBeVisible(); +}); + +test("Settings: the flag escape hatch hides the Coworkers tab", async ({ page }) => { + await page.addInitScript(() => localStorage.setItem("ocw.flag.personas", "0")); + await page.goto("/"); + await page.getByTestId("account-row").click(); + await page.getByRole("button", { name: "Settings", exact: true }).click(); + await expect(page.getByRole("heading", { name: "General" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Coworkers", exact: true })).toHaveCount(0); }); // UX-021: Settings ▸ Models is the shared provider gallery (§39 components). Cards wear diff --git a/surfaces/gui/src/App.tsx b/surfaces/gui/src/App.tsx index 972abd9a..fe139ae8 100644 --- a/surfaces/gui/src/App.tsx +++ b/surfaces/gui/src/App.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState, type PointerEvent } from "react"; import { announceInboxUnlock, + createTempWorkspace, finalizeAutomationRun, getArtifacts, getHealth, @@ -21,6 +22,7 @@ import { deleteSession, renameSession, runAutomation, + saveSessionAsProject, setSessionFlags, setUnattended, Session, @@ -40,13 +42,13 @@ import type { TodoItem, WsEvent, } from "./types"; -import { isProjectScoped } from "./personaScope"; +import { fullPersonaName, isProjectScoped } from "./personaScope"; import { baseName } from "./paths"; import { itemsFromMessages } from "./itemsFromMessages"; import { addTurnUsage, emptyUsage, usageFromMessages } from "./usage"; import { streamMode } from "./streamGate"; import { InboxItemCard } from "./components/InboxItemCard"; -import { isTauri, platformOS, startWindowDrag } from "./tauri"; +import { chooseFolder, isTauri, platformOS, startWindowDrag } from "./tauri"; import { Icon } from "./components/Icon"; import { Sidebar } from "./components/Sidebar"; import { ThinkingBlock, Transcript } from "./components/Transcript"; @@ -55,6 +57,8 @@ import { Markdown } from "./components/Markdown"; import { SearchModal } from "./components/SearchModal"; import { SessionIntro } from "./components/SessionIntro"; import { FolderGate } from "./components/FolderGate"; +import { SessionSetupRow } from "./components/SessionSetupRow"; +import { SendFolderDialog } from "./components/SendFolderDialog"; import { Onboarding } from "./components/Onboarding"; import { UpdateBanner } from "./components/UpdateBanner"; import { ScheduledView } from "./components/ScheduledView"; @@ -158,6 +162,20 @@ function fallbackWorkspace(current: string | null, projects: RecentWorkspace[]): export function App() { const [workspace, setWorkspace] = useState(null); const [branch, setBranch] = useState(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 [workspaceTrustRequest, setWorkspaceTrustRequest] = useState(null); @@ -321,7 +339,12 @@ export function App() { // code-family persona gates a folder like Code, and a knowledge persona starts orphan like Cowork). const [personas, setPersonas] = useState(null); 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); @@ -378,8 +401,15 @@ export function App() { const sessionRef = useRef(null); const scrollRef = useRef(null); - // A prompt to auto-send once the next session connects (used by "Run now"). - const pendingPromptRef = useRef(null); + // A message to auto-send once the next session connects — "Run now" task prompts, and + // 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}). const activeRunRef = useRef<{ taskId: string; runId: string; sessionId: string } | null>(null); @@ -549,15 +579,15 @@ export function App() { return () => window.removeEventListener(PERSONAS_CHANGED, onPersonas); }, [refreshSessions]); - // If the active surface isn't visible (hidden in Settings, or a resumed session landed on a - // hidden surface), fall back to Cowork (always visible). Watches both agent and surfaces so it - // corrects regardless of which settled last. + // If the active persona is DISABLED (turned off in Settings, or a resumed session landed + // on one), fall back to Cowork. This used to key on the legacy sidebar-visibility prefs + // (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(() => { - if ((agent === "chat" && !surfaces.chat) || (agent === "code" && !surfaces.code)) { - switchAgent("cowork"); - } + const p = personaOf(agent); + if (p && !p.enabled) switchAgent("cowork"); // eslint-disable-next-line react-hooks/exhaustive-deps - }, [agent, surfaces]); + }, [agent, personas]); useEffect(() => { if (surface === "session") rememberLastSession(agent, sessionId, workspace); @@ -600,6 +630,8 @@ export function App() { if (d.command_trust?.required) setWorkspaceTrustRequest(d.command_trust); // Cowork: adopt the server-provisioned scratch dir (only when we don't already have one). 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; case "turn_start": setRunning(true); @@ -622,7 +654,11 @@ export function App() { // `input` is model-facing. Surface/dedupe on what the user actually sees. const shown = (typeof d.display === "string" && d.display) || (d.input as string); 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 ? p : [...p, { kind: "user", text: shown, ts: Date.now() / 1000 }]; @@ -795,12 +831,20 @@ export function App() { onEvent: handleEvent, onOpen: () => { 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; if (p) { pendingPromptRef.current = null; - setItems((prev) => [...prev, { kind: "user", text: p, ts: Date.now() / 1000 }]); - sessionRef.current?.userMessage(p); + const shown = p.skill ? `/${p.skill}${p.text ? ` ${p.text}` : ""}` : p.text; + 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), @@ -815,7 +859,7 @@ export function App() { // 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. // 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 // 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]); 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 // `display` sidecar formula so the turn_start dedupe recognizes the local echo. const shown = skill ? `/${skill}${text ? ` ${text}` : ""}` : text; @@ -957,18 +1008,116 @@ export function App() { if (target !== agent) { setAgent(target); if (gatesWorkspace(target)) { - // Never inherit the previous persona's folder — it may be a scratch dir. Clearing it - // also blocks the connection effect, so nothing can chat behind the open gate. + // Never inherit the previous persona's folder — it may be a scratch dir. Clearing + // 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); setBranch(null); - setShowGate(true); - } else setShowGate(false); + } + setShowGate(false); } // 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. - if (!gatesWorkspace(target)) setWorkspace(null); + // server provisions a NEW scratch dir for the new session id. Code keeps its repo — but + // 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()); }; + // 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. // 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 @@ -1016,6 +1165,7 @@ export function App() { setStreaming(""); setRunning(false); if (ag) setAgent(ag); + setTempWorkspace(false); // the `ready` event restores the truth for temp sessions if (!gatesWorkspace(ag)) setShowGate(false); if (ws && ws !== workspace) { 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 // 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.) - const inheritable = gatesWorkspace(agent) ? workspace : null; + // code-family session must never adopt one. Same for a code session's TEMPORARY dir: + // per-conversation, never inherited. (`agent` is still the previous persona here.) + const inheritable = gatesWorkspace(agent) && !tempWorkspace ? workspace : null; if (target) { // 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 r = await runAutomation(taskId); 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 }; openRunSession(r.session_id, r.workspace, r.agent, { id: taskId, title: title || "" }); }; @@ -1193,10 +1344,13 @@ export function App() { const modelDisplay = modelLabels[model]?.split(" · ")[0] || (model.includes(":") ? model.split(":").slice(1).join(":") : model); - // Persona name dropped for this release (owner ask 2026-07-22): personas are hidden, - // so "Coworker" read as noise. The model (+ project folder) are the real fixed facts. - const subtitleParts = [modelDisplay]; - if (isProjectScoped(personaOf(agent)) && workspace) subtitleParts.push(baseName(workspace)); + // UX-029: with the coworker picker shipping, the coworker's name is a fixed fact again + // (it was dropped 2026-07-22 while personas were hidden). For temporary folders the raw + // path never shows — "Temporary folder" + the Save as project… affordance instead. + 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 activeTitle = activeInfo?.title || "New session"; @@ -1362,7 +1516,6 @@ export function App() { onOpenPersona={(id) => { openPersona(id, "session"); }} - onManagePersonas={() => openSettings("personas")} onOpenScheduled={() => setSurface("scheduled")} onOpenAutomation={(id) => { setScheduledOpenId(id); @@ -1474,6 +1627,19 @@ export function App() { {hasHistory && ( {subtitleParts.join(" · ")} + {showSaveAsProject && ( + <> + {" · "} + + + )} )} @@ -1625,6 +1791,21 @@ export function App() { )} + {/* 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__") && ( + openSettings("personas")} + /> + )} 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" && ( + void startTempAndSend()} + onCancel={cancelSendGate} + /> + )} {showGate && surface === "session" && gatesWorkspace(agent) && ( { + 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 { const res = await fetch(`${httpBase()}/v1/workspaces/trusted`); return (await res.json()).workspaces ?? []; diff --git a/surfaces/gui/src/components/AccessSection.tsx b/surfaces/gui/src/components/AccessSection.tsx index 15faa6db..475748b4 100644 --- a/surfaces/gui/src/components/AccessSection.tsx +++ b/surfaces/gui/src/components/AccessSection.tsx @@ -216,8 +216,12 @@ export function AccessSection({ : names.length <= 2 ? names.join(", ") : `${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 - ? baseName(workspace || roots.find((r) => r.primary)?.path || "") || null + ? scratchPrimary + ? "Temporary folder" + : baseName(workspace || "") || null : roots.length > 0 ? `${roots.length} folder${roots.length === 1 ? "" : "s"}` : null; diff --git a/surfaces/gui/src/components/GalleryModal.tsx b/surfaces/gui/src/components/GalleryModal.tsx index dbb36ed8..957a01d0 100644 --- a/surfaces/gui/src/components/GalleryModal.tsx +++ b/surfaces/gui/src/components/GalleryModal.tsx @@ -198,7 +198,7 @@ export function GalleryModal({ )}
- All personas + All coworkers
{visible.map((p) => { @@ -242,15 +242,15 @@ export function GalleryModal({ {source === "team" ? "Nothing shared with your team yet." : q - ? "No personas match your search." - : "No personas published yet."} + ? "No coworkers match your search." + : "No coworkers published yet."}
)} {source !== "team" && teamCount === 0 && (
- 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.
)} @@ -372,7 +372,7 @@ export function GalleryModal({
-
Persona Gallery
+
Coworker Gallery
Curated coworkers · installs stay disabled until you approve them
@@ -381,7 +381,7 @@ export function GalleryModal({ 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" /> )} diff --git a/surfaces/gui/src/components/PersonaView.tsx b/surfaces/gui/src/components/PersonaView.tsx index 74e7b577..95528333 100644 --- a/surfaces/gui/src/components/PersonaView.tsx +++ b/surfaces/gui/src/components/PersonaView.tsx @@ -51,7 +51,7 @@ export function PersonaView({ setError(null); getPersonaDetail(personaId) .then((d) => live && setDetail(d)) - .catch(() => live && setError("Could not load this persona.")); + .catch(() => live && setError("Could not load this coworker.")); getConnectors() .then((list) => live && setByName(indexConnectors(list))) .catch(() => {}); @@ -88,7 +88,7 @@ export function PersonaView({ · )} - Persona + Coworker
); @@ -119,7 +119,7 @@ export function PersonaView({
{detail.enabled ? "Enabled" : "Disabled"} - +
@@ -153,7 +153,7 @@ export function PersonaView({
Connections for full benefit

- 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.

diff --git a/surfaces/gui/src/components/PersonasTab.tsx b/surfaces/gui/src/components/PersonasTab.tsx index a16df8c9..1990bbb6 100644 --- a/surfaces/gui/src/components/PersonasTab.tsx +++ b/surfaces/gui/src/components/PersonasTab.tsx @@ -90,14 +90,14 @@ export function PersonasTab({ onOpenPersona }: { onOpenPersona?: (id: string) => } setConsent(r.consent || []); 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(""); }; return (

- 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.

@@ -167,7 +167,7 @@ export function PersonasTab({ onOpenPersona }: { onOpenPersona?: (id: string) => ) : (
-
Add personas
+
Add coworkers

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.

@@ -218,7 +218,7 @@ export function PersonasTab({ onOpenPersona }: { onOpenPersona?: (id: string) => setSrc(e.target.value)} onKeyDown={(e) => e.key === "Enter" && install()} diff --git a/surfaces/gui/src/components/RootRow.tsx b/surfaces/gui/src/components/RootRow.tsx index 69d5d083..dcd5384a 100644 --- a/surfaces/gui/src/components/RootRow.tsx +++ b/surfaces/gui/src/components/RootRow.tsx @@ -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 // 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({ root, busy, @@ -23,7 +23,7 @@ export function RootRow({ }) { const label = root.primary ? scratchPrimary - ? "Temporary space" + ? "Temporary folder" : baseName(root.path) : root.label; return ( diff --git a/surfaces/gui/src/components/SendFolderDialog.tsx b/surfaces/gui/src/components/SendFolderDialog.tsx new file mode 100644 index 00000000..61d49880 --- /dev/null +++ b/surfaces/gui/src/components/SendFolderDialog.tsx @@ -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([]); + 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 ( +
+
e.stopPropagation()} + > +

+ Where should {coworkerName} work? +

+

+ Code work happens inside a folder — pick your project, or start somewhere temporary. +

+ {recents + .filter((w) => w.exists) + .slice(0, 4) + .map((w) => ( + + ))} +
+ + +
+ {error &&
{error}
} +

+ 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. +

+
+
+ ); +} diff --git a/surfaces/gui/src/components/SessionSetupRow.tsx b/surfaces/gui/src/components/SessionSetupRow.tsx new file mode 100644 index 00000000..5bad4856 --- /dev/null +++ b/surfaces/gui/src/components/SessionSetupRow.tsx @@ -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(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 ( +
+ {openMenu &&
setOpenMenu(null)} />} + + {/* Coworker chip — name only, no icon (owner call). */} +
+ + {openMenu === "coworker" && ( +
+ {personas.map((p) => ( + + ))} +
+ +
+
+ )} +
+ + {/* Folder chip — only for personas that work in a folder. */} + {props.showFolder && ( +
+ + {openMenu === "folder" && ( +
+ {(recents || []) + .filter((w) => w.exists) + .slice(0, 5) + .map((w) => ( + + ))} +
w.exists) ? "border-t border-line mt-1 pt-1" : ""}> + +
+ {error &&
{error}
} +
+ )} +
+ )} +
+ ); +} diff --git a/surfaces/gui/src/components/SettingsView.tsx b/surfaces/gui/src/components/SettingsView.tsx index ac2ef488..d294e73c 100644 --- a/surfaces/gui/src/components/SettingsView.tsx +++ b/surfaces/gui/src/components/SettingsView.tsx @@ -73,7 +73,7 @@ const SET_TABS: { { key: "skills", label: "Skills", icon: "book" }, { key: "voice", label: "Voice input", icon: "mic" }, { key: "memory", label: "Memory", icon: "archive" }, - { key: "personas", label: "Personas", icon: "sparkle" }, + { key: "personas", label: "Coworkers", icon: "sparkle" }, ]; export function SettingsView({ @@ -378,8 +378,8 @@ function PersonasSection({ onOpenPersona }: { onOpenPersona?: (id: string) => vo return (
- {/* 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 + row (UX-029), so the old ▾ persona menu is gone. Starts the last-used persona; + the setup row re-targets the draft in place. */} +
+ +
{/* 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. */} @@ -1284,95 +1286,3 @@ export function Sidebar(props: Props) {
); } - -// 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 ( -
-
- - {!solo && ( - - )} -
- {open && ( - <> -
setOpen(false)} /> -
-
- Start a session as -
- {enabled.map((p) => ( - - ))} - {showPersonas() && ( -
- -
- )} -
- - )} -
- ); -} diff --git a/surfaces/gui/src/flags.ts b/surfaces/gui/src/flags.ts index 9d90bebc..969c165f 100644 --- a/surfaces/gui/src/flags.ts +++ b/surfaces/gui/src/flags.ts @@ -15,7 +15,7 @@ function flag(key: string, fallback: boolean): boolean { return fallback; } -/** Personas management is hidden for launch (owner call, 2026-07-19): the Settings tab - * and the "Manage personas…" menu entry stay off until the persona catalog is ready. - * The e2e suite sets `ocw.flag.personas` to keep the hidden flows covered. */ -export const showPersonas = () => flag("ocw.flag.personas", false); +/** Coworkers shipped with UX-029 (the composer's setup-row picker): the Settings ▸ + * Coworkers tab and management flows are ON by default. `ocw.flag.personas` = "0" is the + * escape hatch to hide them again. (Hidden for launch 2026-07-19 → enabled 2026-08-10.) */ +export const showPersonas = () => flag("ocw.flag.personas", true); diff --git a/tests/test_persona_connections.py b/tests/test_persona_connections.py index 58ac5b18..704c3514 100644 --- a/tests/test_persona_connections.py +++ b/tests/test_persona_connections.py @@ -65,7 +65,7 @@ def test_persona_detail_endpoint(tmp_path, monkeypatch): # identity + capabilities (from the manifest/entry) assert detail["id"] == "ops" 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 ( detail["workspace"] == "deliverable" ) # §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)) 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 resp = client.post("/v1/personas/ops/enable", json={"enabled": True}).json() diff --git a/tests/test_persona_registry.py b/tests/test_persona_registry.py index 99ad55f0..d85148da 100644 --- a/tests/test_persona_registry.py +++ b/tests/test_persona_registry.py @@ -20,29 +20,27 @@ def test_builtins_present(tmp_path): 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) sidebar = reg.sidebar() ids = [e["name"] for e in sidebar] - # A fresh install offers ONLY the default persona (owner call 2026-07-09); - # everything else is opt-in from Settings ▸ Personas. - 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()] + # Built-ins ship enabled (UX-029: the coworker picker is their front door); Chat + # stays default-hidden via the surfaced axis. Installed personas remain opt-in. assert ids[0] == "cowork" 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) - assert reg.is_surfaced("chat") is False # default-hidden - assert reg.is_enabled("chat") is False # opt-in like every non-default persona + assert reg.is_surfaced("chat") is False # default-hidden from the grouped nav + assert reg.is_enabled("chat") is True # builtins ship enabled (UX-029) assert reg.agent("chat").name == "chat" # live sessions keep resolving - # The user can enable it from the Personas tab (enable implies surface). - reg.set_enabled("chat", True) + # Surfacing it adds it to the sidebar picker too. + reg.set_surfaced("chat", True) assert "chat" in [e["name"] for e in reg.sidebar()] diff --git a/tests/test_server.py b/tests/test_server.py index 495de260..b897d3c9 100644 --- a/tests/test_server.py +++ b/tests/test_server.py @@ -60,10 +60,10 @@ def test_chat_completions_openai_shape(tmp_path): def test_agents_and_memory_rest(tmp_path): client = _client(tmp_path, []) agents = client.get("/v1/agents").json()["agents"] - # The picker lists enabled+surfaced personas — a fresh install is cowork-only - # (non-default personas ship disabled, opt-in from Settings ▸ Personas). + # The picker lists enabled+surfaced personas — builtins ship enabled (UX-029); + # Chat stays default-hidden via the surfaced axis. 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) added = client.post("/v1/memory", json={"content": "prefer pathlib"}).json() diff --git a/tests/test_temp_workspace.py b/tests/test_temp_workspace.py new file mode 100644 index 00000000..ef430e1f --- /dev/null +++ b/tests/test_temp_workspace.py @@ -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()