mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-11 14:50:14 +00:00
coworker picker: setup chips above composer, folder pick at send (UX-029)
Per-session coworker+folder chips replace the sidebar split-button picker; code family gets a send-time folder dialog with git-ready temp dirs and Save as project. Builtins ship enabled; user-facing noun is Coworker; personas flag now defaults on.
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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", ""))
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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()}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user