mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-12 15:20:19 +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:
@@ -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
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user