coworker picker: setup chips above composer, folder pick at send (UX-029)

Per-session coworker+folder chips replace the sidebar split-button picker; code family gets a send-time folder dialog with git-ready temp dirs and Save as project.
Builtins ship enabled; user-facing noun is Coworker; personas flag now defaults on.
This commit is contained in:
Rohit C Prasad
2026-08-10 21:43:02 -07:00
committed by Rohit P
parent 9702c86c7f
commit 3d13c7d699
28 changed files with 923 additions and 318 deletions
+1 -1
View File
@@ -30,7 +30,7 @@ test("no topbar opener; the Access header IS the ambient glance; expanding edits
await expect(body.getByText("Sources")).toBeVisible();
await expect(body.getByText("Slack", { exact: true })).toBeVisible();
await expect(body.getByText("email context for morning summaries")).toBeVisible();
await expect(body.getByTestId("drawer-directories").getByText("Temporary space")).toBeVisible();
await expect(body.getByTestId("drawer-directories").getByText("Temporary folder")).toBeVisible();
await expect(page.getByRole("dialog")).toHaveCount(0);
// Channels is a chat capability, not a two_way one: Slack gets the drill-down, GitHub
+79 -29
View File
@@ -1,43 +1,93 @@
import { test, expect } from "./fixtures";
// §16 workspace collapse: the persona FAMILY alone decides the workspace behavior.
// code → an explicit project folder, enforced by the FolderGate (no chat-behind-it escape)
// knowledge → starts orphan on a transparent scratch dir — never gated
// (The mock's Ops persona is knowledge-family with zero sessions, so picking it exercises the
// brand-new-session path, not a resume.)
// UX-029: the persona FAMILY still decides workspace behavior, but code-family
// enforcement moved from a modal gate at session start to the SEND moment:
// code → send with no folder → "Where should … work?" dialog (recents / native
// picker / "Start in a temporary folder", git-init'd, created only now)
// knowledge → starts orphan on a transparent temporary dir — never gated
// The coworker pick lives in the setup chip row above the composer, only before the
// first message of a new session; afterwards the row leaves and the facts move to the
// session header.
const personaMenu = (page: import("@playwright/test").Page) => page.locator(".newsplit-menu");
async function startAs(page: import("@playwright/test").Page, persona: RegExp) {
await page.getByLabel("Choose a persona").click();
await personaMenu(page).getByRole("button", { name: persona }).click();
async function newDraftAs(page: import("@playwright/test").Page, coworker: RegExp) {
await page.getByText("New session").first().click();
await page.getByTestId("coworker-chip").click();
await page.locator(".setup-menu").getByRole("button", { name: coworker }).click();
}
test("knowledge persona: new session starts instantly, no folder gate", async ({ page }) => {
test("knowledge coworker: new session starts instantly, no gate, no dialog", async ({ page }) => {
await page.goto("/");
await expect(page.getByPlaceholder(/Ask the coworker/)).toBeVisible();
await newDraftAs(page, /Ops Coworker/);
await startAs(page, /Ops/);
await expect(page.locator(".gate-overlay")).toHaveCount(0);
await expect(page.getByPlaceholder(/Ask the coworker/)).toBeVisible();
const box = page.getByPlaceholder(/Ask the coworker/);
await box.fill("hello there");
await page.getByRole("button", { name: "Send" }).click();
await expect(page.getByText(/Echo: hello there/)).toBeVisible();
await expect(page.getByTestId("send-folder-dialog")).toHaveCount(0);
});
test("code persona: the folder gate blocks until a project is chosen", async ({ page }) => {
test("code coworker: send with no folder asks where to work; temp folder sends the message", async ({
page,
}) => {
await page.goto("/");
await expect(page.getByPlaceholder(/Ask the coworker/)).toBeVisible();
await newDraftAs(page, /Code Coworker/);
await startAs(page, /Code/);
const gate = page.locator(".gate-overlay");
await expect(gate).toBeVisible();
await expect(gate.getByText("Choose a project folder")).toBeVisible();
// No escape hatch: the gate offers pick-a-folder only (no "switch to Chat" — owner call, §16).
await expect(gate.getByText(/chat/i)).toHaveCount(0);
await gate.getByPlaceholder("/path/to/your/project").fill("/tmp/e2e-project");
await gate.getByRole("button", { name: "Open", exact: true }).click();
// Gate clears, the session is rooted in the chosen folder, and the code composer is live.
// No modal gate up front — the composer is live and the draft is composable.
await expect(page.locator(".gate-overlay")).toHaveCount(0);
await expect(page.getByPlaceholder(/Ask the coder/)).toBeVisible();
await page.getByPlaceholder(/Ask the coder/).fill("fix the tests");
await page.getByRole("button", { name: "Send" }).click();
const dlg = page.getByTestId("send-folder-dialog");
await expect(dlg).toBeVisible();
await expect(dlg.getByText("Where should Code Coworker work?")).toBeVisible();
await dlg.getByTestId("start-temp-folder").click();
// The message flies as soon as the choice lands — no second send click, and the local
// echo isn't duplicated by turn_start (the notice sits between them).
await expect(page.getByText(/Echo: fix the tests/)).toBeVisible();
await expect(page.locator(".main-scroll").getByText("fix the tests", { exact: true })).toHaveCount(1);
await expect(page.getByText("Temporary folder created · git initialized")).toBeVisible();
// The raw temp path never shows: header says "Temporary folder" + Save as project….
const sub = page.getByTestId("session-subtitle");
await expect(sub).toContainText("Code Coworker");
await expect(sub).toContainText("Temporary folder");
await expect(sub).not.toContainText("ow-temp");
await expect(page.getByTestId("save-as-project")).toBeVisible();
// One-time pick: the setup row left with the first message.
await expect(page.getByTestId("setup-row")).toHaveCount(0);
// A NEW session never inherits the temporary dir — the folder chip starts fresh.
await page.getByText("New session").first().click();
await expect(page.getByTestId("folder-chip")).toContainText("Choose folder");
});
test("code coworker: Choose a folder… binds the picked project and sends", async ({ page }) => {
await page.goto("/");
await newDraftAs(page, /Code Coworker/);
await page.getByPlaceholder(/Ask the coder/).fill("hello repo");
await page.getByRole("button", { name: "Send" }).click();
// Native pick is mocked server-side → /tmp/picked-folder.
await page.getByTestId("send-folder-dialog").getByRole("button", { name: "Choose a folder…" }).click();
await expect(page.getByText(/Echo: hello repo/)).toBeVisible();
await expect(page.getByTestId("session-subtitle")).toContainText("picked-folder");
await expect(page.getByTestId("save-as-project")).toHaveCount(0);
});
test("escape restores the draft instead of losing it", async ({ page }) => {
await page.goto("/");
await newDraftAs(page, /Code Coworker/);
const box = page.getByPlaceholder(/Ask the coder/);
await box.fill("precious draft");
await page.getByRole("button", { name: "Send" }).click();
await expect(page.getByTestId("send-folder-dialog")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByTestId("send-folder-dialog")).toHaveCount(0);
await expect(box).toHaveValue("precious draft");
});
+9
View File
@@ -947,6 +947,15 @@ export async function mockApi(page: import("@playwright/test").Page) {
const b = req.postDataJSON();
return json({ ok: true, path: b.path, git_branch: "main" });
}
if (p.endsWith("/v1/workspaces/temp") && m === "POST") {
// UX-029: "Start in a temporary folder" — created at send time, git-ready.
const b = req.postDataJSON();
return json({ ok: true, path: `/tmp/ow-temp/${b.session_id}`, git: b.git !== false });
}
if (/\/v1\/sessions\/[^/]+\/save-as-project$/.test(p) && m === "POST") {
const b = req.postDataJSON();
return json({ ok: true, path: b.path });
}
// must precede the /v1/personas/{id} catch-all (install matches it too)
if (p.endsWith("/v1/personas/install") && m === "POST") {
const b = req.postDataJSON();
+3 -5
View File
@@ -6,12 +6,10 @@ import { expect } from "@playwright/test";
import { test } from "./fixtures";
async function openPersonas(page) {
// Personas is launch-flagged off by default — these suites cover the flagged-on flows.
await page.addInitScript(() => localStorage.setItem("ocw.flag.personas", "1"));
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Settings", exact: true }).click();
await page.getByRole("button", { name: "Personas", exact: true }).click();
await page.getByRole("button", { name: "Coworkers", exact: true }).click();
await expect(page.getByTestId("gallery-link")).toBeVisible();
}
@@ -65,9 +63,9 @@ test("signed in: featured carousel + list; solo page installs informed; Done ret
await expect(page.getByTestId("gallery-team-teaser")).toContainText("coming soon");
// Search narrows the list.
await page.getByPlaceholder("Search personas").fill("recruit");
await page.getByPlaceholder("Search coworkers").fill("recruit");
await expect(page.getByTestId("gallery-sales")).not.toBeVisible();
await page.getByPlaceholder("Search personas").fill("");
await page.getByPlaceholder("Search coworkers").fill("");
// Solo page: pitch + manifest-derived capabilities BEFORE install.
await page.getByTestId("gallery-sales").click();
+12 -15
View File
@@ -1,10 +1,5 @@
import { test, expect } from "./fixtures";
// Personas is launch-flagged off by default — this suite covers the flagged-on flows.
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => localStorage.setItem("ocw.flag.personas", "1"));
});
// Regression for the invisible-after-install bug (2026-07-03): enabling a persona in
// Settings ▸ Personas must surface it EVERYWHERE without a reload — the New-Session picker and
// the grouped sidebar — via the PERSONAS_CHANGED event (and backend enable-implies-surface).
@@ -15,18 +10,19 @@ test("enabling an installed persona surfaces it in picker + sidebar without relo
await page.goto("/");
const sidebar = page.locator(".sidebar");
// Disabled install: absent from the persona picker and the grouped sidebar.
await page.getByLabel("Choose a persona").click();
const menu = page.locator(".newsplit-menu");
// Disabled install: absent from the composer's coworker picker and the grouped sidebar.
await page.getByText("New session").first().click();
await page.getByTestId("coworker-chip").click();
const menu = page.locator(".setup-menu");
await expect(menu).toBeVisible();
await expect(menu.getByText("Acme Notes")).toHaveCount(0);
await page.locator(".fixed.inset-0.z-20").click(); // close via backdrop
await page.locator(".fixed.inset-0.z-20").click({ position: { x: 5, y: 5 } }); // close via backdrop (menu sits over center)
await expect(sidebar.getByText("Acme Notes")).toHaveCount(0);
// Enable it on the Personas page.
// Enable it on the Coworkers page.
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Settings", exact: true }).click();
await page.getByRole("button", { name: "Personas", exact: true }).click();
await page.getByRole("button", { name: "Coworkers", exact: true }).click();
const row = page.locator(".divide-y > div").filter({ hasText: "Acme Notes" });
// Controlled checkbox: the DOM state flips only after the POST round-trip, so click + expect
// (a plain .check() asserts the state synchronously and fails).
@@ -36,8 +32,9 @@ test("enabling an installed persona surfaces it in picker + sidebar without relo
// No reload: the sidebar group and the picker both pick it up via PERSONAS_CHANGED.
await expect(sidebar.getByText("Acme Notes")).toBeVisible();
await page.getByLabel("Choose a persona").click();
await expect(page.locator(".newsplit-menu").getByText("Acme Notes")).toBeVisible();
await page.getByText("New session").first().click();
await page.getByTestId("coworker-chip").click();
await expect(page.locator(".setup-menu").getByText("Acme Notes")).toBeVisible();
});
// Disable-archives (§18): disabling a persona archives its conversations, so the confirm must
@@ -52,7 +49,7 @@ test("disabling a persona with conversations asks first, then archives them", as
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Settings", exact: true }).click();
await page.getByRole("button", { name: "Personas", exact: true }).click();
await page.getByRole("button", { name: "Coworkers", exact: true }).click();
const row = page.locator(".divide-y > div").filter({ hasText: "Ops Coworker" });
const enabled = row.getByRole("checkbox", { name: "Enabled" });
@@ -78,7 +75,7 @@ test("disabling a persona with no conversations skips the confirm", async ({ pag
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Settings", exact: true }).click();
await page.getByRole("button", { name: "Personas", exact: true }).click();
await page.getByRole("button", { name: "Coworkers", exact: true }).click();
const row = page.locator(".divide-y > div").filter({ hasText: "Code" });
const enabled = row.getByRole("checkbox", { name: "Enabled" });
await enabled.click();
+2 -2
View File
@@ -14,8 +14,8 @@ test("working directories: add folders with the read-only / read-write gate", as
const dirs = page.getByTestId("drawer-directories");
await expect(dirs.getByText("Folders")).toBeVisible();
// The primary is the writable scratch workspace (Cowork shows it as "Temporary space").
await expect(dirs.getByText("Temporary space")).toBeVisible();
// The primary is the writable scratch workspace (Cowork shows it as "Temporary folder").
await expect(dirs.getByText("Temporary folder")).toBeVisible();
// Add a folder — the gate defaults to read-only (Allow writes OFF). The Browse button works
// in the BROWSER too (sidecar-opened native picker; owner report 2026-07-04).
+4 -4
View File
@@ -33,7 +33,7 @@ test("top-left cluster renders only while the sidebar is collapsed", async ({ pa
await expect(page.getByTestId("topbar-cluster")).toHaveCount(0);
});
test("facts subtitle: absent on a fresh session, model-only after the first turn, inert", async ({
test("facts subtitle: absent on a fresh session, coworker + model after the first turn, inert", async ({
page,
}) => {
await page.goto("/");
@@ -51,10 +51,10 @@ test("facts subtitle: absent on a fresh session, model-only after the first turn
await page.getByRole("button", { name: "Send" }).click();
await expect(page.getByText(/Echo: hello/)).toBeVisible();
// Model only — no persona name (owner ask 2026-07-22: personas are hidden this release),
// and the subtitle is a plain fact line, not a button to the persona page.
// Coworker + model (UX-029 restored the coworker name — the picker shipped), and the
// subtitle is a plain fact line, not a button to the persona page.
const sub = page.getByTestId("session-subtitle");
await expect(sub).toHaveText("Claude Opus 4.8");
await expect(sub).toHaveText("Coworker · Claude Opus 4.8");
await expect(page.locator(".dd").filter({ hasText: "Claude Opus 4.8" })).toBeVisible();
await sub.click();
await expect(page.getByRole("button", { name: "Back", exact: true })).toHaveCount(0);
+16 -8
View File
@@ -2,7 +2,7 @@ import { test, expect } from "./fixtures";
// Guards the Settings-as-page refactor (§13, IA per UX-021): the ⚙ menu opens a full-page
// surface with a left sub-nav — General · Models · Voice input — and each section renders.
// Files is a card inside General; Personas is launch-flagged off.
// Files is a card inside General; Coworkers ships on (flag "0" hides it).
test("Settings opens as a full page and navigates sections", async ({ page }) => {
await page.goto("/");
@@ -15,9 +15,9 @@ test("Settings opens as a full page and navigates sections", async ({ page }) =>
for (const label of ["General", "Models", "Voice input"]) {
await expect(page.getByRole("button", { name: label, exact: true })).toBeVisible();
}
// Folded/hidden tabs: Files is a General card now; Personas is launch-flagged off.
// Folded tabs: Files is a General card now; Coworkers ships as its own tab (UX-029).
await expect(page.getByRole("button", { name: "Files", exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Personas", exact: true })).toHaveCount(0);
await expect(page.getByRole("button", { name: "Coworkers", exact: true })).toBeVisible();
// The Files card lives inside General.
await expect(page.getByText("Each conversation gets its own folder")).toBeVisible();
@@ -26,14 +26,22 @@ test("Settings opens as a full page and navigates sections", async ({ page }) =>
await expect(page.getByTestId("set-provider-openai")).toBeVisible();
});
// The launch flag brings the Personas tab back (the gallery/persona suites rely on it).
test("Settings: Personas tab returns behind the launch flag", async ({ page }) => {
await page.addInitScript(() => localStorage.setItem("ocw.flag.personas", "1"));
// The flag's "0" escape hatch hides the tab again (the default is on — UX-029).
test("Settings: Coworkers tab opens by default; flag \"0\" hides it", async ({ page }) => {
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Settings", exact: true }).click();
await page.getByRole("button", { name: "Personas", exact: true }).click();
await expect(page.getByText("Add personas")).toBeVisible();
await page.getByRole("button", { name: "Coworkers", exact: true }).click();
await expect(page.getByText("Add coworkers")).toBeVisible();
});
test("Settings: the flag escape hatch hides the Coworkers tab", async ({ page }) => {
await page.addInitScript(() => localStorage.setItem("ocw.flag.personas", "0"));
await page.goto("/");
await page.getByTestId("account-row").click();
await page.getByRole("button", { name: "Settings", exact: true }).click();
await expect(page.getByRole("heading", { name: "General" })).toBeVisible();
await expect(page.getByRole("button", { name: "Coworkers", exact: true })).toHaveCount(0);
});
// UX-021: Settings ▸ Models is the shared provider gallery (§39 components). Cards wear
+223 -32
View File
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useRef, useState, type PointerEvent } from "react";
import {
announceInboxUnlock,
createTempWorkspace,
finalizeAutomationRun,
getArtifacts,
getHealth,
@@ -21,6 +22,7 @@ import {
deleteSession,
renameSession,
runAutomation,
saveSessionAsProject,
setSessionFlags,
setUnattended,
Session,
@@ -40,13 +42,13 @@ import type {
TodoItem,
WsEvent,
} from "./types";
import { isProjectScoped } from "./personaScope";
import { fullPersonaName, isProjectScoped } from "./personaScope";
import { baseName } from "./paths";
import { itemsFromMessages } from "./itemsFromMessages";
import { addTurnUsage, emptyUsage, usageFromMessages } from "./usage";
import { streamMode } from "./streamGate";
import { InboxItemCard } from "./components/InboxItemCard";
import { isTauri, platformOS, startWindowDrag } from "./tauri";
import { chooseFolder, isTauri, platformOS, startWindowDrag } from "./tauri";
import { Icon } from "./components/Icon";
import { Sidebar } from "./components/Sidebar";
import { ThinkingBlock, Transcript } from "./components/Transcript";
@@ -55,6 +57,8 @@ import { Markdown } from "./components/Markdown";
import { SearchModal } from "./components/SearchModal";
import { SessionIntro } from "./components/SessionIntro";
import { FolderGate } from "./components/FolderGate";
import { SessionSetupRow } from "./components/SessionSetupRow";
import { SendFolderDialog } from "./components/SendFolderDialog";
import { Onboarding } from "./components/Onboarding";
import { UpdateBanner } from "./components/UpdateBanner";
import { ScheduledView } from "./components/ScheduledView";
@@ -158,6 +162,20 @@ function fallbackWorkspace(current: string | null, projects: RecentWorkspace[]):
export function App() {
const [workspace, setWorkspace] = useState<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 [workspaceTrustRequest, setWorkspaceTrustRequest] =
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).
const [personas, setPersonas] = useState<Persona[] | null>(null);
useEffect(() => {
getPersonas().then(setPersonas).catch(() => {});
const load = () => getPersonas().then(setPersonas).catch(() => {});
load();
// The composer's coworker picker is always mounted on a fresh session — refetch on
// mutations (enable/install from Settings) instead of going stale.
window.addEventListener(PERSONAS_CHANGED, load);
return () => window.removeEventListener(PERSONAS_CHANGED, load);
}, []);
const personaOf = (a: string) => personas?.find((p) => p.id === a);
@@ -378,8 +401,15 @@ export function App() {
const sessionRef = useRef<Session | null>(null);
const scrollRef = useRef<HTMLDivElement | null>(null);
// A prompt to auto-send once the next session connects (used by "Run now").
const pendingPromptRef = useRef<string | null>(null);
// A message to auto-send once the next session connects "Run now" task prompts, and
// UX-029's deferred first send (folder resolved at send time → reconnect → message goes).
const pendingPromptRef = useRef<{
text: string;
attachments?: Attachment[];
skill?: string;
model?: string;
notice?: string; // e.g. "Temporary folder created · git initialized", shown after the message
} | null>(null);
// The in-flight manual run to finalize after its first turn ({taskId, runId, sessionId}).
const activeRunRef = useRef<{ taskId: string; runId: string; sessionId: string } | null>(null);
@@ -549,15 +579,15 @@ export function App() {
return () => window.removeEventListener(PERSONAS_CHANGED, onPersonas);
}, [refreshSessions]);
// If the active surface isn't visible (hidden in Settings, or a resumed session landed on a
// hidden surface), fall back to Cowork (always visible). Watches both agent and surfaces so it
// corrects regardless of which settled last.
// If the active persona is DISABLED (turned off in Settings, or a resumed session landed
// on one), fall back to Cowork. This used to key on the legacy sidebar-visibility prefs
// (show_chat/show_code) — with the composer picker shipped (UX-029), enablement is the
// one visibility axis, and a deliberately picked coworker must never be reverted.
useEffect(() => {
if ((agent === "chat" && !surfaces.chat) || (agent === "code" && !surfaces.code)) {
switchAgent("cowork");
}
const p = personaOf(agent);
if (p && !p.enabled) switchAgent("cowork");
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [agent, surfaces]);
}, [agent, personas]);
useEffect(() => {
if (surface === "session") rememberLastSession(agent, sessionId, workspace);
@@ -600,6 +630,8 @@ export function App() {
if (d.command_trust?.required) setWorkspaceTrustRequest(d.command_trust);
// Cowork: adopt the server-provisioned scratch dir (only when we don't already have one).
if (d.workspace) setWorkspace((cur) => cur || d.workspace);
// UX-029: server truth on whether this session runs in a temporary folder.
if (typeof d.temp_workspace === "boolean") setTempWorkspace(d.temp_workspace);
break;
case "turn_start":
setRunning(true);
@@ -622,7 +654,11 @@ export function App() {
// `input` is model-facing. Surface/dedupe on what the user actually sees.
const shown = (typeof d.display === "string" && d.display) || (d.input as string);
setItems((p) => {
const last = p[p.length - 1];
// Look past trailing notices — the UX-029 "Temporary folder created" line
// sits between the local echo and this event's arrival.
let i = p.length - 1;
while (i >= 0 && p[i].kind === "notice") i--;
const last = p[i];
return last && last.kind === "user" && last.text === shown
? p
: [...p, { kind: "user", text: shown, ts: Date.now() / 1000 }];
@@ -795,12 +831,20 @@ export function App() {
onEvent: handleEvent,
onOpen: () => {
setConnected(true);
// Auto-send the task prompt once a "Run now" session connects.
// Auto-send the pending message once the session connects ("Run now" prompts and
// UX-029's deferred first send).
const p = pendingPromptRef.current;
if (p) {
pendingPromptRef.current = null;
setItems((prev) => [...prev, { kind: "user", text: p, ts: Date.now() / 1000 }]);
sessionRef.current?.userMessage(p);
const shown = p.skill ? `/${p.skill}${p.text ? ` ${p.text}` : ""}` : p.text;
setItems((prev) => [
...prev,
{ kind: "user", text: shown, attachments: p.attachments, ts: Date.now() / 1000 },
...(p.notice
? [{ kind: "notice", tone: "info", text: p.notice } as Item]
: []),
]);
sessionRef.current?.userMessage(p.text, p.attachments, p.model, p.skill);
}
},
onClose: () => setConnected(false),
@@ -815,7 +859,7 @@ export function App() {
// first connect, dropping the user's first message (the "send twice" bug). The scratch
// dir is deterministic from `sessionId` server-side, so skipping that reconnect is safe.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [booting, sessionId, agent, refreshSessions]);
}, [booting, sessionId, agent, refreshSessions, connectNonce]);
// Stream-following (FB-004): auto-scroll only while the user is AT the bottom, so scrolling
// up to read during a streaming turn sticks. `atBottomRef` is the live truth (per scroll
@@ -890,6 +934,13 @@ export function App() {
}, [surface, sessionId, browserRefreshKey, markUnattended]);
const send = (text: string, attachments?: Attachment[], skill?: string) => {
// UX-029: folder enforcement AT SEND. A code-family session with no folder has no
// socket yet (the connect effect waits) — stash the message and ask where to work;
// it goes out the moment the dialog resolves.
if (gatesWorkspace(agent) && !workspace) {
setSendGate({ text, attachments, skill });
return;
}
// Force-run shows exactly what the user typed: "/name rest". Must match the server's
// `display` sidecar formula so the turn_start dedupe recognizes the local echo.
const shown = skill ? `/${skill}${text ? ` ${text}` : ""}` : text;
@@ -957,18 +1008,116 @@ export function App() {
if (target !== agent) {
setAgent(target);
if (gatesWorkspace(target)) {
// Never inherit the previous persona's folder — it may be a scratch dir. Clearing it
// also blocks the connection effect, so nothing can chat behind the open gate.
// Never inherit the previous persona's folder — it may be a scratch dir. Clearing
// it also blocks the connection effect; the setup row's folder chip (or the
// send-time dialog) provides the folder — no modal gate up front (UX-029).
setWorkspace(null);
setBranch(null);
setShowGate(true);
} else setShowGate(false);
}
setShowGate(false);
}
// Knowledge family: a new conversation starts fresh (orphan) — clear the workspace so the
// server provisions a NEW scratch dir for the new session id. Code keeps its repo.
if (!gatesWorkspace(target)) setWorkspace(null);
// server provisions a NEW scratch dir for the new session id. Code keeps its repo — but
// never a TEMPORARY dir (per-conversation by definition; the next session picks anew).
if (!gatesWorkspace(target) || tempWorkspace) {
setWorkspace(null);
setBranch(null);
}
setTempWorkspace(false);
setSessionId(newId());
};
// UX-029: re-target the DRAFT session (no messages yet) to another coworker. Unlike
// switchAgent this never resumes that coworker's last conversation — the user is
// composing a new one. A fresh id keeps knowledge families' per-conversation scratch
// dirs clean and re-triggers the connection effect.
const pickCoworker = (id: string) => {
if (id === agent) return;
setAgent(id);
setWorkspace(null);
setBranch(null);
setTempWorkspace(false);
setShowGate(false);
setSessionId(newId());
};
// UX-029: the setup row's folder chip — bind the draft to a folder before the first
// message. A fresh id re-triggers the connection effect with the folder attached.
const pickDraftFolder = (path: string, b?: string | null) => {
setWorkspace(path);
setBranch(b ?? null);
setTempWorkspace(false);
setSessionId(newId());
getRecentWorkspaces().then(setProjects).catch(() => {});
};
// UX-029 send-time dialog resolutions: bind the folder, park the stashed message for
// the reconnect's onOpen, and let it fly. The user's send already happened — no second
// click needed.
const resolveSendFolder = (path: string, b?: string | null) => {
const gate = sendGate;
if (!gate) return;
setSendGate(null);
setWorkspace(path);
setBranch(b ?? null);
setTempWorkspace(false);
pendingPromptRef.current = { ...gate, model };
setSessionId(newId());
getRecentWorkspaces().then(setProjects).catch(() => {});
};
const startTempAndSend = async () => {
const gate = sendGate;
if (!gate) return;
const sid = newId();
const res = await createTempWorkspace(sid, true);
if (!res.ok || !res.path) {
setSendGate(null);
setItems((p) => [
...p,
{ kind: "notice", tone: "warn", text: res.error || "Could not create a temporary folder." },
]);
prefillComposer(gate.skill ? `/${gate.skill} ${gate.text}` : gate.text, gate.attachments);
return;
}
setSendGate(null);
setWorkspace(res.path);
setBranch(null);
setTempWorkspace(true);
pendingPromptRef.current = {
...gate,
model,
notice: res.git ? "Temporary folder created · git initialized" : "Temporary folder created",
};
setSessionId(sid);
};
const cancelSendGate = () => {
const gate = sendGate;
setSendGate(null);
// Give the draft back — the composer cleared it when the user hit send.
if (gate) prefillComposer(gate.skill ? `/${gate.skill} ${gate.text}` : gate.text, gate.attachments);
};
// UX-029 "Save as project…": move the temporary folder somewhere real, then reconnect
// so the engine rebinds to the new path (same session id — the transcript stays).
const saveAsProject = async () => {
if (running) return;
const dest = await chooseFolder();
if (!dest) return;
const res = await saveSessionAsProject(sessionId, dest);
if (!res.ok || !res.path) {
setItems((p) => [
...p,
{ kind: "notice", tone: "warn", text: res.error || "Could not save as a project." },
]);
return;
}
const newPath = res.path;
setWorkspace(newPath);
setBranch(null);
setTempWorkspace(false);
setItems((p) => [
...p,
{ kind: "notice", tone: "info", text: `Saved as a project — now working in ${baseName(newPath)}.` },
]);
setConnectNonce((n) => n + 1);
refreshSessions();
};
// Inbox → session: the item carries its session's workspace/agent, so open it directly.
// UX-026: 5s top-right toast when a SCHEDULED automation run starts (never for
// manual Run-now — the user is already watching). Rides the app-wide /ws/events
@@ -1016,6 +1165,7 @@ export function App() {
setStreaming("");
setRunning(false);
if (ag) setAgent(ag);
setTempWorkspace(false); // the `ready` event restores the truth for temp sessions
if (!gatesWorkspace(ag)) setShowGate(false);
if (ws && ws !== workspace) {
setWorkspace(ws); // switch project to the session's folder
@@ -1048,8 +1198,9 @@ export function App() {
// The live workspace is only a valid fallback for a gated persona if it came from
// another gated persona — a knowledge persona's workspace is a scratch dir, and a
// code-family session must never adopt one. (`agent` is still the previous persona here.)
const inheritable = gatesWorkspace(agent) ? workspace : null;
// code-family session must never adopt one. Same for a code session's TEMPORARY dir:
// per-conversation, never inherited. (`agent` is still the previous persona here.)
const inheritable = gatesWorkspace(agent) && !tempWorkspace ? workspace : null;
if (target) {
// Code falls back to a recent folder; Cowork resumes its scratch (target.workspace) or
@@ -1174,7 +1325,7 @@ export function App() {
const runTaskNow = async (taskId: string, title?: string) => {
const r = await runAutomation(taskId);
if (!r || !r.ok) return;
pendingPromptRef.current = r.prompt;
pendingPromptRef.current = { text: r.prompt };
activeRunRef.current = { taskId, runId: r.run_id, sessionId: r.session_id };
openRunSession(r.session_id, r.workspace, r.agent, { id: taskId, title: title || "" });
};
@@ -1193,10 +1344,13 @@ export function App() {
const modelDisplay =
modelLabels[model]?.split(" · ")[0] ||
(model.includes(":") ? model.split(":").slice(1).join(":") : model);
// Persona name dropped for this release (owner ask 2026-07-22): personas are hidden,
// so "Coworker" read as noise. The model (+ project folder) are the real fixed facts.
const subtitleParts = [modelDisplay];
if (isProjectScoped(personaOf(agent)) && workspace) subtitleParts.push(baseName(workspace));
// UX-029: with the coworker picker shipping, the coworker's name is a fixed fact again
// (it was dropped 2026-07-22 while personas were hidden). For temporary folders the raw
// path never shows — "Temporary folder" + the Save as project… affordance instead.
const subtitleParts = [fullPersonaName(personaOf(agent)?.name, agent), modelDisplay];
if (isProjectScoped(personaOf(agent)) && workspace)
subtitleParts.push(tempWorkspace ? "Temporary folder" : baseName(workspace));
const showSaveAsProject = hasHistory && tempWorkspace && isProjectScoped(personaOf(agent));
const activeInfo = sessions.find((s) => s.session_id === sessionId);
const activeTitle = activeInfo?.title || "New session";
@@ -1362,7 +1516,6 @@ export function App() {
onOpenPersona={(id) => {
openPersona(id, "session");
}}
onManagePersonas={() => openSettings("personas")}
onOpenScheduled={() => setSurface("scheduled")}
onOpenAutomation={(id) => {
setScheduledOpenId(id);
@@ -1474,6 +1627,19 @@ export function App() {
{hasHistory && (
<span className="title-sub" data-testid="session-subtitle">
{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>
)}
</div>
@@ -1625,6 +1791,21 @@ export function App() {
</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
mode={mode}
model={model}
@@ -1707,7 +1888,7 @@ export function App() {
projectScoped={isProjectScoped(personaOf(agent))}
workspace={workspace || undefined}
branch={branch}
scratchPrimary={agent === "cowork"}
scratchPrimary={agent === "cowork" || tempWorkspace}
openAccessKey={accessKey}
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) && (
<FolderGate
create={gateCreate}
+31
View File
@@ -97,6 +97,37 @@ export async function openWorkspace(
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[]> {
const res = await fetch(`${httpBase()}/v1/workspaces/trusted`);
return (await res.json()).workspaces ?? [];
@@ -216,8 +216,12 @@ export function AccessSection({
: names.length <= 2
? names.join(", ")
: `${names.slice(0, 2).join(", ")} +${names.length - 2}`;
// A temporary dir's raw name (the session id) never shows — say "Temporary folder"; a
// draft with no folder picked yet shows none at all (UX-029).
const folderPart = projectScoped
? baseName(workspace || roots.find((r) => r.primary)?.path || "") || null
? scratchPrimary
? "Temporary folder"
: baseName(workspace || "") || null
: roots.length > 0
? `${roots.length} folder${roots.length === 1 ? "" : "s"}`
: null;
+6 -6
View File
@@ -198,7 +198,7 @@ export function GalleryModal({
)}
<div className="text-[11px] uppercase tracking-[0.05em] text-faint font-semibold mb-2">
All personas
All coworkers
</div>
<div className="space-y-2">
{visible.map((p) => {
@@ -242,15 +242,15 @@ export function GalleryModal({
{source === "team"
? "Nothing shared with your team yet."
: q
? "No personas match your search."
: "No personas published yet."}
? "No coworkers match your search."
: "No coworkers published yet."}
</div>
)}
</div>
{source !== "team" && teamCount === 0 && (
<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>
@@ -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="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="text-[15px] font-semibold">Persona Gallery</div>
<div className="text-[15px] font-semibold">Coworker Gallery</div>
<div className="text-[12px] text-muted">
Curated coworkers · installs stay disabled until you approve them
</div>
@@ -381,7 +381,7 @@ export function GalleryModal({
<input
value={query}
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"
/>
)}
+4 -4
View File
@@ -51,7 +51,7 @@ export function PersonaView({
setError(null);
getPersonaDetail(personaId)
.then((d) => live && setDetail(d))
.catch(() => live && setError("Could not load this persona."));
.catch(() => live && setError("Could not load this coworker."));
getConnectors()
.then((list) => live && setByName(indexConnectors(list)))
.catch(() => {});
@@ -88,7 +88,7 @@ export function PersonaView({
<span className="text-faint">·</span>
</>
)}
<span className="text-[13px] font-semibold">Persona</span>
<span className="text-[13px] font-semibold">Coworker</span>
</div>
);
@@ -119,7 +119,7 @@ export function PersonaView({
</div>
<div className="ml-auto flex items-center gap-2">
<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>
</header>
@@ -153,7 +153,7 @@ export function PersonaView({
<section>
<div className={`${SEC_H} mb-1`}>Connections for full benefit</div>
<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.
</p>
<div className="rounded-xl2 border border-line overflow-hidden">
+6 -6
View File
@@ -90,14 +90,14 @@ export function PersonasTab({ onOpenPersona }: { onOpenPersona?: (id: string) =>
}
setConsent(r.consent || []);
if (r.personas) setPersonas(r.personas);
setMsg(`Installed ${(r.consent || []).length} persona(s) — review and enable below.`);
setMsg(`Installed ${(r.consent || []).length} coworker(s) — review and enable below.`);
setSrc("");
};
return (
<div>
<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.
</p>
@@ -167,7 +167,7 @@ export function PersonasTab({ onOpenPersona }: { onOpenPersona?: (id: string) =>
) : (
<button
className="text-faint hover:text-danger shrink-0 p-1"
title="Delete this persona"
title="Delete this coworker"
aria-label={`Delete ${p.name}`}
data-testid={`persona-delete-${p.id}`}
onClick={() => setConfirmDel(p.id)}
@@ -205,10 +205,10 @@ export function PersonasTab({ onOpenPersona }: { onOpenPersona?: (id: string) =>
))}
</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">
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.
</p>
<div className="flex items-center gap-2">
@@ -218,7 +218,7 @@ export function PersonasTab({ onOpenPersona }: { onOpenPersona?: (id: string) =>
</select>
<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}
onChange={(e) => setSrc(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && install()}
+2 -2
View File
@@ -4,7 +4,7 @@ import { baseName } from "../paths";
// One directory row, shared by the composer popover and the session start panel. The primary is the
// session's bound workspace — the repo/folder for Code/Ops (shown by name), or a throwaway scratch
// for Cowork (shown as "Temporary space"). It's always read-write and can't be removed.
// for Cowork (shown as "Temporary folder"). It's always read-write and can't be removed.
export function RootRow({
root,
busy,
@@ -23,7 +23,7 @@ export function RootRow({
}) {
const label = root.primary
? scratchPrimary
? "Temporary space"
? "Temporary folder"
: baseName(root.path)
: root.label;
return (
@@ -0,0 +1,104 @@
import { useEffect, useState } from "react";
import { getRecentWorkspaces, openWorkspace, type RecentWorkspace } from "../api";
import { chooseFolder } from "../tauri";
import { baseName } from "../paths";
import { Icon } from "./Icon";
// UX-029: folder enforcement AT SEND, not at session start. A code-family coworker with no
// folder picked gets this dialog when the user hits send; the message goes out the moment a
// choice lands (recents / native picker / temporary folder). Escape restores the draft.
interface Props {
coworkerName: string;
onPick: (path: string, branch?: string | null) => void;
onTemp: () => void;
onCancel: () => void;
}
export function SendFolderDialog({ coworkerName, onPick, onTemp, onCancel }: Props) {
const [recents, setRecents] = useState<RecentWorkspace[]>([]);
const [error, setError] = useState("");
const [busy, setBusy] = useState(false);
useEffect(() => {
getRecentWorkspaces().then(setRecents).catch(() => {});
}, []);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onCancel();
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [onCancel]);
const pick = async (path: string) => {
setError("");
const res = await openWorkspace(path);
if (res.ok) onPick(res.path, res.git_branch);
else setError(res.error || "could not open that folder");
};
const browse = async () => {
const picked = await chooseFolder();
if (picked) await pick(picked);
};
return (
<div className="gate-overlay" onClick={onCancel}>
<div
className="w-[410px] bg-panel border border-line rounded-xl2 shadow-2xl p-[18px]"
data-testid="send-folder-dialog"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-[14.5px] font-semibold text-ink mb-1">
Where should {coworkerName} work?
</h3>
<p className="text-[12.5px] text-muted mb-3">
Code work happens inside a folder pick your project, or start somewhere temporary.
</p>
{recents
.filter((w) => w.exists)
.slice(0, 4)
.map((w) => (
<button
key={w.path}
className="w-full flex items-center gap-2.5 px-2.5 py-2 mb-1.5 rounded-lg border border-line hover:border-lineStrong hover:bg-paper text-left"
onClick={() => void pick(w.path)}
title={w.path}
>
<Icon name="folder" size={13} className="shrink-0 text-muted" />
<span className="text-[12.5px] text-ink truncate">{baseName(w.path)}</span>
<span className="ml-auto text-[11.5px] text-faint truncate max-w-[45%]">{w.path}</span>
</button>
))}
<div className="flex gap-2 mt-3">
<button
className="flex-1 text-center text-[12.5px] px-2.5 py-2 rounded-lg border border-lineStrong text-ink hover:bg-paper"
onClick={() => void browse()}
disabled={busy}
>
Choose a folder
</button>
<button
className="flex-1 text-center text-[12.5px] px-2.5 py-2 rounded-lg bg-accent text-white font-semibold hover:opacity-95"
data-testid="start-temp-folder"
onClick={() => {
if (busy) return;
setBusy(true);
onTemp();
}}
disabled={busy}
>
Start in a temporary folder
</button>
</div>
{error && <div className="mt-2 text-[11.5px] text-warnInk">{error}</div>}
<p className="text-[11px] text-faint mt-2.5">
A temporary folder is created only when you send, with git ready you can save it as a
project later. Your message sends as soon as you choose.
</p>
</div>
</div>
);
}
@@ -0,0 +1,148 @@
import { useState } from "react";
import { getRecentWorkspaces, openWorkspace, type Persona, type RecentWorkspace } from "../api";
import { chooseFolder } from "../tauri";
import { fullPersonaName } from "../personaScope";
import { baseName } from "../paths";
import { Icon } from "./Icon";
// UX-029: the session-setup row — per-SESSION choices (coworker + folder) in their own
// quiet chip row above the composer, a different species from the per-MESSAGE controls
// inside it. Rendered only before the first message; after that the whole row leaves and
// its facts move to the session header. Chips are borderless (position, not a border,
// marks them as different) and the coworker chip carries no icon — both owner calls.
interface Props {
personas: Persona[] | null;
agent: string;
// The folder chip renders only for personas that work in a folder (Chat hides it).
showFolder: boolean;
// The user's explicit folder pick for this draft, if any (never a temporary dir's path).
folderName: string | null;
onPickCoworker: (id: string) => void;
onPickFolder: (path: string, branch?: string | null) => void;
onManage: () => void;
}
export function SessionSetupRow(props: Props) {
const [openMenu, setOpenMenu] = useState<"coworker" | "folder" | null>(null);
const [recents, setRecents] = useState<RecentWorkspace[] | null>(null);
const [error, setError] = useState("");
const personas = (props.personas || []).filter((p) => p.enabled);
const current = personas.find((p) => p.id === props.agent);
const toggle = (menu: "coworker" | "folder") => {
setError("");
if (menu === "folder" && openMenu !== "folder") {
getRecentWorkspaces().then(setRecents).catch(() => setRecents([]));
}
setOpenMenu((cur) => (cur === menu ? null : menu));
};
const pickFolder = async (path: string) => {
const res = await openWorkspace(path);
if (!res.ok) {
setError(res.error || "could not open that folder");
return;
}
setOpenMenu(null);
props.onPickFolder(res.path, res.git_branch);
};
const browse = async () => {
const picked = await chooseFolder();
if (picked) await pickFolder(picked);
};
const chip =
"relative inline-flex items-center gap-1.5 px-2 py-1.5 rounded-lg text-[12.5px] text-muted hover:text-ink hover:bg-paper cursor-pointer select-none whitespace-nowrap";
return (
<div className="max-w-3xl mx-auto mb-1.5 px-1 flex items-center gap-1.5" data-testid="setup-row">
{openMenu && <div className="fixed inset-0 z-20" onClick={() => setOpenMenu(null)} />}
{/* Coworker chip — name only, no icon (owner call). */}
<div className="relative">
<button className={chip} data-testid="coworker-chip" onClick={() => toggle("coworker")}>
{fullPersonaName(current?.name, props.agent)}
<Icon name="chevronDown" size={12} className="text-faint" />
</button>
{openMenu === "coworker" && (
<div className="setup-menu absolute bottom-full mb-1.5 left-0 z-30 w-[320px] bg-panel border border-line rounded-xl2 shadow-xl p-1">
{personas.map((p) => (
<button
key={p.id}
className={
"w-full text-left px-2.5 py-2 rounded-lg hover:bg-paper " +
(p.id === props.agent ? "bg-accentSoft/50" : "")
}
onClick={() => {
setOpenMenu(null);
props.onPickCoworker(p.id);
}}
>
<span className="block text-[13px] font-medium text-ink">
{fullPersonaName(p.name, p.id)}
</span>
{p.tagline && (
<span className="block text-[11.5px] text-muted truncate">{p.tagline}</span>
)}
</button>
))}
<div className="border-t border-line mt-1 pt-1">
<button
className="w-full text-left px-2.5 py-1.5 rounded-lg hover:bg-paper text-[12px] text-accent"
onClick={() => {
setOpenMenu(null);
props.onManage();
}}
>
Manage coworkers
</button>
</div>
</div>
)}
</div>
{/* Folder chip — only for personas that work in a folder. */}
{props.showFolder && (
<div className="relative">
<button className={chip} data-testid="folder-chip" onClick={() => toggle("folder")}>
<Icon name="folder" size={13} />
<span className="max-w-[220px] truncate">{props.folderName || "Choose folder"}</span>
<Icon name="chevronDown" size={12} className="text-faint" />
</button>
{openMenu === "folder" && (
<div className="setup-menu absolute bottom-full mb-1.5 left-0 z-30 w-[280px] bg-panel border border-line rounded-xl2 shadow-xl p-1">
{(recents || [])
.filter((w) => w.exists)
.slice(0, 5)
.map((w) => (
<button
key={w.path}
className="w-full text-left flex items-start gap-2.5 px-2.5 py-2 rounded-lg hover:bg-paper"
onClick={() => void pickFolder(w.path)}
title={w.path}
>
<Icon name="folder" size={13} className="mt-0.5 shrink-0 text-muted" />
<span className="min-w-0">
<span className="block text-[13px] font-medium text-ink truncate">{baseName(w.path)}</span>
<span className="block text-[11.5px] text-faint truncate">{w.path}</span>
</span>
</button>
))}
<div className={(recents || []).some((w) => w.exists) ? "border-t border-line mt-1 pt-1" : ""}>
<button
className="w-full text-left px-2.5 py-1.5 rounded-lg hover:bg-paper text-[12px] text-accent"
onClick={() => void browse()}
>
Choose another folder
</button>
</div>
{error && <div className="px-2.5 py-1 text-[11.5px] text-warnInk">{error}</div>}
</div>
)}
</div>
)}
</div>
);
}
+4 -4
View File
@@ -73,7 +73,7 @@ const SET_TABS: {
{ key: "skills", label: "Skills", icon: "book" },
{ key: "voice", label: "Voice input", icon: "mic" },
{ key: "memory", label: "Memory", icon: "archive" },
{ key: "personas", label: "Personas", icon: "sparkle" },
{ key: "personas", label: "Coworkers", icon: "sparkle" },
];
export function SettingsView({
@@ -378,8 +378,8 @@ function PersonasSection({ onOpenPersona }: { onOpenPersona?: (id: string) => vo
return (
<section>
<PanelHead
title="Personas"
sub="Which coworkers are enabled and shown in the picker, plus installing new persona bundles."
title="Coworkers"
sub="Which coworkers are enabled and shown in the picker, plus installing new coworker bundles."
/>
<PersonasTab key={galleryBump} onOpenPersona={onOpenPersona} />
<button
@@ -389,7 +389,7 @@ function PersonasSection({ onOpenPersona }: { onOpenPersona?: (id: string) => vo
>
<Icon name="sparkle" size={16} className="text-accent shrink-0" />
<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">
Curated coworkers from the OpenWorker team see what each can do before installing.
</span>
+11 -63
View File
@@ -1,5 +1,5 @@
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 type { SessionInfo } from "../types";
@@ -53,7 +53,6 @@ const baseProps = {
onTogglePin: vi.fn(),
onManage: vi.fn(),
onOpenPersona: vi.fn(),
onManagePersonas: vi.fn(),
onOpenScheduled: vi.fn(),
onOpenAutomation: vi.fn(),
onOpenIntegrations: vi.fn(),
@@ -72,7 +71,7 @@ afterEach(() => {
});
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([
{ match: "/v1/personas", method: "GET", json: PERSONAS },
{ 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.
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(await screen.findByText("Persona"));
fireEvent.click(await screen.findByText("Coworker"));
// POSTs the new layout pref.
await waitFor(() => {
@@ -198,68 +197,17 @@ describe("From Slack group (§31)", () => {
});
});
describe("New-session split button", () => {
it("collapses to a plain button when only one persona is enabled", 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");
describe("New session button", () => {
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 },
{ match: "/v1/settings", method: "GET", json: { nav_layout: "flat" } },
]);
render(<Sidebar {...baseProps} />);
await screen.findByLabelText("Group and filter conversations");
fireEvent.click(screen.getByLabelText("Choose a persona"));
const menu = (await screen.findByText("Start a session as")).closest(".newsplit-menu") as HTMLElement;
expect(within(menu).getByText("Ops")).toBeTruthy();
expect(within(menu).queryByText("Manage personas…")).toBeNull();
await screen.findByText("incident watch");
expect(screen.queryByLabelText("Choose a persona")).toBeNull();
fireEvent.click(screen.getByText("New session"));
expect(baseProps.onNewSession).toHaveBeenCalledWith("cowork");
});
});
+20 -110
View File
@@ -23,10 +23,9 @@ import type { SessionInfo } from "../types";
import { isProjectScoped, shortPersonaName } from "../personaScope";
import { ConnectorIcon } from "../connectors/ConnectorIcon";
import { Icon, type IconName } from "./Icon";
import { PersonaGlyph, personaGlyph } from "./personaIcon";
import { personaGlyph } from "./personaIcon";
import { SearchModal } from "./SearchModal";
import { baseName } from "../paths";
import { showPersonas } from "../flags";
// 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.
@@ -128,9 +127,9 @@ interface Props {
onArchiveSession: (id: string, archived: boolean) => void;
onTogglePin: (id: string, pinned: boolean) => 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;
onManagePersonas: () => void;
onOpenScheduled: () => void;
// Scheduled-band row click: open the Automations surface ON that automation (UX-023).
onOpenAutomation: (id: string) => void;
@@ -276,12 +275,11 @@ export function Sidebar(props: Props) {
}, []);
const personaOf = (id: string) => personas?.find((p) => p.id === id);
// Sidebar layout (§7): "grouped" = the per-persona accordion; "flat" = a single ungrouped list
// (Pinned + Recent). Read the persisted preference on load; ABSENT falls back by the
// Personas flag — with personas hidden for launch, a per-persona accordion groups by
// a concept the user can't see, so the default is the flat chronological list
// (owner call 2026-07-20). An explicit stored choice always wins.
const defaultLayout: "flat" | "grouped" = showPersonas() ? "grouped" : "flat";
// Sidebar layout (§7): "grouped" = the per-coworker accordion; "flat" = a single
// ungrouped list (Pinned + Recent). Flat stays the default even with Coworkers shipped
// (UX-029 flips the flag for the picker, not the nav shape — the flat chronological
// list default is the 2026-07-20 owner call). An explicit stored choice always wins.
const defaultLayout: "flat" | "grouped" = "flat";
const [layout, setLayout] = useState<"flat" | "grouped">(defaultLayout);
// Sessions shown per group before "Show more" — Settings ▸ Appearance ▸ Sidebar.
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">
Group by
</div>
{([["grouped", "Persona"], ["flat", "Chronological"]] as ["flat" | "grouped", string][]).map(
{([["grouped", "Coworker"], ["flat", "Chronological"]] as ["flat" | "grouped", string][]).map(
([key, label]) => (
<button
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>
{/* New session: split button — primary starts the last-used persona; ▾ picks a specific one. */}
<NewSessionSplit
personas={personas}
current={props.agent}
onNew={props.onNewSession}
onManage={props.onManagePersonas}
/>
{/* New session: a plain button the coworker pick moved to the composer's setup
row (UX-029), so the old persona menu is gone. Starts the last-used persona;
the setup row re-targets the draft in place. */}
<div className="px-3 pt-2">
<button
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
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>
);
}
// New-session split button (§8): the primary action starts a session with the last-used persona
// (`current`); the ▾ opens a menu of the enabled personas (from /v1/personas) plus a "Manage
// personas…" entry. A plain custom split control — the pill-shaped Dropdown doesn't fit this shape.
function NewSessionSplit({
personas,
current,
onNew,
onManage,
}: {
personas: Persona[] | null;
current: string;
onNew: (agent: string) => void;
onManage: () => void;
}) {
const [open, setOpen] = useState(false);
const enabled = (personas || []).filter((p) => p.enabled);
// With a single enabled persona there is nothing to pick — the split collapses to a plain
// button (owner ask 2026-07-09). `personas === null` (still loading) keeps the split so the
// control doesn't visibly change shape once the list arrives with 2+.
const solo = personas !== null && enabled.length <= 1;
return (
<div className="px-3 pt-2 relative">
<div className="flex">
<button
className={
"newsplit-primary flex-1 text-left px-3 py-2 bg-accent text-white text-[13px] font-medium hover:opacity-95 flex items-center gap-2 " +
(solo ? "rounded-lg" : "rounded-l-lg")
}
onClick={() => onNew(solo && enabled.length === 1 ? enabled[0].id : current)}
>
<Icon name="plus" size={15} className="shrink-0" /> New session
</button>
{!solo && (
<button
className="px-2.5 rounded-r-lg bg-accent text-white border-l border-white/25 hover:opacity-95 flex items-center"
title="Start with a specific persona"
aria-label="Choose a persona"
onClick={() => setOpen((v) => !v)}
>
<Icon name="chevronDown" size={13} />
</button>
)}
</div>
{open && (
<>
<div className="fixed inset-0 z-20" onClick={() => setOpen(false)} />
<div className="newsplit-menu absolute left-3 right-3 mt-1 z-30 bg-panel border border-line rounded-xl2 shadow-xl p-1">
<div className="px-2 py-1 text-[10.5px] uppercase tracking-[0.06em] text-faint font-semibold">
Start a session as
</div>
{enabled.map((p) => (
<button
key={p.id}
className="w-full flex items-center gap-2.5 px-2 py-1.5 rounded-lg hover:bg-paper text-left"
onClick={() => {
setOpen(false);
onNew(p.id);
}}
>
<span className="w-6 h-6 rounded-md bg-paper border border-line grid place-items-center text-muted shrink-0">
<PersonaGlyph icon={p.icon} family={p.family} size={12} />
</span>
<span className="min-w-0">
<span className="block text-[13px] font-medium truncate">
{shortPersonaName(p.name, p.id)}
</span>
{p.tagline && (
<span className="block text-[11px] text-muted truncate">{p.tagline}</span>
)}
</span>
</button>
))}
{showPersonas() && (
<div className="border-t border-line mt-1 pt-1">
<button
className="w-full px-2 py-1.5 rounded-lg hover:bg-paper text-left text-[12.5px] text-muted"
onClick={() => {
setOpen(false);
onManage();
}}
>
Manage personas
</button>
</div>
)}
</div>
</>
)}
</div>
);
}
+4 -4
View File
@@ -15,7 +15,7 @@ function flag(key: string, fallback: boolean): boolean {
return fallback;
}
/** Personas management is hidden for launch (owner call, 2026-07-19): the Settings tab
* and the "Manage personas…" menu entry stay off until the persona catalog is ready.
* The e2e suite sets `ocw.flag.personas` to keep the hidden flows covered. */
export const showPersonas = () => flag("ocw.flag.personas", false);
/** Coworkers shipped with UX-029 (the composer's setup-row picker): the Settings
* Coworkers tab and management flows are ON by default. `ocw.flag.personas` = "0" is the
* escape hatch to hide them again. (Hidden for launch 2026-07-19 enabled 2026-08-10.) */
export const showPersonas = () => flag("ocw.flag.personas", true);