diff --git a/coworker/personas/builtin/cloud-posture/media/cloud-posture-coworker-1.jpg b/coworker/personas/builtin/cloud-posture/media/cloud-posture-coworker-1.jpg new file mode 100644 index 00000000..39d92cd5 Binary files /dev/null and b/coworker/personas/builtin/cloud-posture/media/cloud-posture-coworker-1.jpg differ diff --git a/coworker/personas/builtin/cloud-posture/media/cloud-posture-coworker-2.jpg b/coworker/personas/builtin/cloud-posture/media/cloud-posture-coworker-2.jpg new file mode 100644 index 00000000..0b299ab0 Binary files /dev/null and b/coworker/personas/builtin/cloud-posture/media/cloud-posture-coworker-2.jpg differ diff --git a/coworker/personas/builtin/security/media/security-coworker-screenshot-1.jpg b/coworker/personas/builtin/security/media/security-coworker-screenshot-1.jpg new file mode 100644 index 00000000..b77dd40c Binary files /dev/null and b/coworker/personas/builtin/security/media/security-coworker-screenshot-1.jpg differ diff --git a/surfaces/gui/e2e/fixtures.ts b/surfaces/gui/e2e/fixtures.ts index 45740d17..00a587d7 100644 --- a/surfaces/gui/e2e/fixtures.ts +++ b/surfaces/gui/e2e/fixtures.ts @@ -53,15 +53,18 @@ const SETTINGS = { }, }; +// UX-035 lineup: Chat is gone; Code ships disabled; the security bundles group under +// "Security"; ops is ships:false (visible here because the mock plays an internal build). const PERSONAS = { + internal: true, personas: [ - { id: "cowork", name: "OpenWorker", icon: "cowork", tagline: "Produce a deliverable — research, analysis, scripts", needs_workspace: true, builtin: true, family: "knowledge", workspace: "deliverable", tools: ["files", "search"], enabled: true, surfaced: true, default: true }, - { id: "code", name: "Code", icon: "code", tagline: "Work in a codebase — files, git, shell", needs_workspace: true, builtin: true, family: "code", workspace: "git", tools: ["code_files", "git"], enabled: true, surfaced: true, default: false }, - { id: "chat", name: "Chat", icon: "chat", tagline: "Quick questions — no workspace", needs_workspace: false, builtin: true, family: "knowledge", workspace: "none", tools: [], enabled: false, surfaced: false, default: false }, - { id: "ops", name: "Ops Coworker", icon: "wrench", tagline: "Operate and investigate — runbooks, logs, infrastructure", needs_workspace: true, builtin: true, family: "knowledge", workspace: "deliverable", tools: ["files", "shell"], enabled: true, surfaced: true, default: false }, + { id: "cowork", name: "OpenWorker", icon: "cowork", tagline: "Produce a deliverable — research, analysis, scripts", needs_workspace: true, builtin: true, family: "knowledge", workspace: "deliverable", tools: ["files", "search"], enabled: true, surfaced: true, default: true, ships: true, group: "general" }, + { id: "code", name: "Code", icon: "code", tagline: "Work in a codebase — files, git, shell", needs_workspace: true, builtin: true, family: "code", workspace: "git", tools: ["code_files", "git"], enabled: false, surfaced: false, default: false, ships: true, group: "general" }, + { id: "security", name: "Security Coworker", icon: "shield", tagline: "Find and fix security issues — scan, triage, PR", needs_workspace: true, builtin: true, family: "code", workspace: "git", tools: ["code_files", "git", "shell"], enabled: true, surfaced: true, default: false, ships: true, group: "security" }, + { id: "ops", name: "Ops Coworker", icon: "wrench", tagline: "Operate and investigate — runbooks, logs, infrastructure", needs_workspace: true, builtin: true, family: "knowledge", workspace: "deliverable", tools: ["files", "shell"], enabled: true, surfaced: true, default: false, ships: false, group: "general" }, // A non-builtin install (disabled pending consent — invisible to picker specs) so the // Personas page's delete/enable affordances have a target. - { id: "acme-notes", name: "Acme Notes", icon: "pencil", tagline: "Acme's note-taking coworker", needs_workspace: true, builtin: false, family: "knowledge", workspace: "deliverable", tools: ["files"], enabled: false, surfaced: false, default: false }, + { id: "acme-notes", name: "Acme Notes", icon: "pencil", tagline: "Acme's note-taking coworker", needs_workspace: true, builtin: false, family: "knowledge", workspace: "deliverable", tools: ["files"], enabled: false, surfaced: false, default: false, ships: true, group: "general" }, ], }; @@ -1395,8 +1398,24 @@ export async function mockApi(page: import("@playwright/test").Page) { personas.splice(i, 1); return json({ ok: true, personas }); } - if (/\/v1\/personas\/[^/]+$/.test(p)) return json(PERSONA_DETAIL); - if (p.endsWith("/v1/personas")) return json({ personas }); + if (/\/v1\/personas\/[^/]+$/.test(p)) { + // Detail merges the live list row over the static shape, so enable/surface/default + // state and builtin-ness track the same mutable array the list serves. + const id = decodeURIComponent(p.split("/").pop() || ""); + const base = personas.find((x) => x.id === id); + return json({ + ...PERSONA_DETAIL, + media: [], + surfaced: true, + default: false, + builtin: true, + group: "general", + ...(base || {}), + recommends: PERSONA_DETAIL.recommends, + default_connections: PERSONA_DETAIL.default_connections, + }); + } + if (p.endsWith("/v1/personas")) return json({ internal: PERSONAS.internal, personas }); if (p.endsWith("/v1/sessions")) return json({ sessions }); if (/\/v1\/connectors\/slack\/unauthorized\/[^/]+$/.test(p) && m === "POST") { const id = p.split("/").pop(); diff --git a/surfaces/gui/e2e/gallery.spec.ts b/surfaces/gui/e2e/gallery.spec.ts index d72f3900..cf9d91c9 100644 --- a/surfaces/gui/e2e/gallery.spec.ts +++ b/surfaces/gui/e2e/gallery.spec.ts @@ -45,6 +45,8 @@ test("signed out: modal prompts for sign-in, manual install path unaffected", as // Esc closes; the Personas page (with its dir/Git importer) is still there. await page.keyboard.press("Escape"); await expect(page.getByTestId("gallery-modal")).not.toBeVisible(); + // The manual installer (collapsed disclosure, UX-035) is still there. + await page.getByTestId("install-disclosure").click(); await expect(page.getByRole("button", { name: "Install", exact: true })).toBeVisible(); }); @@ -98,12 +100,12 @@ test("back link returns from the solo page to the catalog", async ({ page }) => test("delete: non-builtin personas removable after confirm; built-ins are not", async ({ page, }) => { + // UX-035: delete moved off the list rows onto the coworker detail page. await openPersonas(page); - // Built-ins expose no delete affordance. - await expect(page.getByTestId("persona-delete-cowork")).toHaveCount(0); - // Non-builtin: trash → inline confirm → row gone (works signed out). await expect(page.getByText("Acme Notes")).toBeVisible(); - await page.getByTestId("persona-delete-acme-notes").click(); - await page.getByTestId("persona-delete-confirm-acme-notes").click(); + await page.getByTestId("persona-configure-acme-notes").click(); + await page.getByTestId("persona-delete").click(); + await page.getByTestId("persona-delete-confirm").click(); + // Back on the list, the row is gone (works signed out). await expect(page.getByText("Acme Notes")).not.toBeVisible(); }); diff --git a/surfaces/gui/e2e/persona-surfacing.spec.ts b/surfaces/gui/e2e/persona-surfacing.spec.ts index ec8aaf7a..1d79475c 100644 --- a/surfaces/gui/e2e/persona-surfacing.spec.ts +++ b/surfaces/gui/e2e/persona-surfacing.spec.ts @@ -26,7 +26,7 @@ test("enabling an installed persona surfaces it in picker + sidebar without relo 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). - const enabled = row.getByRole("checkbox", { name: "Enabled" }); + const enabled = row.getByRole("switch"); await enabled.click(); await expect(enabled).toBeChecked(); @@ -50,8 +50,10 @@ 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: "Coworkers", exact: true }).click(); + // Ops is ships:false — it lives in the collapsed "Not in this release" group. + await page.getByTestId("unshipped-disclosure").click(); const row = page.locator(".divide-y > div").filter({ hasText: "Ops Coworker" }); - const enabled = row.getByRole("checkbox", { name: "Enabled" }); + const enabled = row.getByRole("switch"); // Unchecking only ARMS the confirm — the flag must not flip yet. await enabled.click(); @@ -76,9 +78,11 @@ test("disabling a persona with no conversations skips the confirm", async ({ pag await page.getByTestId("account-row").click(); await page.getByRole("button", { name: "Settings", 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" }); + // Security ships enabled and has no conversations in the fixtures (Code now ships + // disabled, so it can't exercise the disable path). + const row = page.locator(".divide-y > div").filter({ hasText: "Security Coworker" }); + const enabled = row.getByRole("switch"); await enabled.click(); - await expect(page.getByTestId("persona-disable-warning-code")).toHaveCount(0); + await expect(page.getByTestId("persona-disable-warning-security")).toHaveCount(0); await expect(enabled).not.toBeChecked(); }); diff --git a/surfaces/gui/e2e/settings.spec.ts b/surfaces/gui/e2e/settings.spec.ts index 3acbf3fa..a405defa 100644 --- a/surfaces/gui/e2e/settings.spec.ts +++ b/surfaces/gui/e2e/settings.spec.ts @@ -32,7 +32,7 @@ test("Settings: Coworkers tab opens by default; flag \"0\" hides it", async ({ p await page.getByTestId("account-row").click(); await page.getByRole("button", { name: "Settings", exact: true }).click(); await page.getByRole("button", { name: "Coworkers", exact: true }).click(); - await expect(page.getByText("Add coworkers")).toBeVisible(); + await expect(page.getByTestId("install-disclosure")).toBeVisible(); }); test("Settings: the flag escape hatch hides the Coworkers tab", async ({ page }) => { diff --git a/surfaces/gui/e2e/sharing.spec.ts b/surfaces/gui/e2e/sharing.spec.ts index bdc4d29a..f6ff043e 100644 --- a/surfaces/gui/e2e/sharing.spec.ts +++ b/surfaces/gui/e2e/sharing.spec.ts @@ -10,8 +10,9 @@ test("picker's Import door lands on Settings ▸ Coworkers at the Add section", await page.getByTestId("coworker-chip").click(); await page.getByTestId("import-coworker").click(); - // Settings ▸ Coworkers opened, with the Add section (the import surface) present. - await expect(page.getByText("Add coworkers")).toBeVisible(); + // Settings ▸ Coworkers opened, with the installer disclosure auto-opened (UX-035: + // it's collapsed by default; the Import door pops it). + await expect(page.getByTestId("install-disclosure")).toBeVisible(); await expect(page.getByRole("combobox")).toBeVisible(); }); @@ -23,7 +24,9 @@ test("zip import: trust warning leads, tools collapse behind a chevron, replaces await page.getByRole("button", { name: "Settings", exact: true }).click(); await page.getByRole("button", { name: "Coworkers", exact: true }).click(); - // Pick the Bundle zip mode and feed a file through the hidden input. + // Open the installer disclosure, pick the Bundle zip mode, feed a file through + // the hidden input. + await page.getByTestId("install-disclosure").click(); await page.getByRole("combobox").selectOption("zip"); await page.getByTestId("persona-zip-input").setInputFiles({ name: "team-sec.zip", @@ -53,10 +56,10 @@ test("zip import: trust warning leads, tools collapse behind a chevron, replaces // Imported coworker landed disabled in the list above, pending consent — // and the card itself carries the Enable action (no hunting back up the list). const row = page.locator(".divide-y > div").filter({ hasText: "Team Security Coworker" }); - await expect(row.getByRole("checkbox", { name: "Enabled" })).not.toBeChecked(); + await expect(row.getByRole("switch")).toHaveAttribute("aria-checked", "false"); await card.getByTestId("consent-enable-team-sec").click(); await expect(card.getByTestId("consent-enabled")).toContainText("it's in your coworker picker"); - await expect(row.getByRole("checkbox", { name: "Enabled" })).toBeChecked(); + await expect(row.getByRole("switch")).toHaveAttribute("aria-checked", "true"); }); test("Export… zips an installed coworker's bundle to a chosen folder", async ({ page }) => { @@ -65,8 +68,9 @@ test("Export… zips an installed coworker's bundle to a chosen folder", async ( await page.getByRole("button", { name: "Settings", exact: true }).click(); await page.getByRole("button", { name: "Coworkers", exact: true }).click(); - // The installed (non-builtin) fixture persona carries the Export affordance; - // the native folder pick is server-mocked → /tmp/picked-folder. - await page.getByTestId("persona-export-acme-notes").click(); + // Export moved to the coworker detail page (UX-035); the native folder pick is + // server-mocked → /tmp/picked-folder. + await page.getByTestId("persona-configure-acme-notes").click(); + await page.getByTestId("persona-export").click(); await expect(page.getByText("Exported to /tmp/picked-folder/acme-notes-coworker-v1.zip")).toBeVisible(); }); diff --git a/surfaces/gui/src/api.ts b/surfaces/gui/src/api.ts index f1b0dce1..9623a2be 100644 --- a/surfaces/gui/src/api.ts +++ b/surfaces/gui/src/api.ts @@ -1038,6 +1038,9 @@ export interface Persona { enabled: boolean; surfaced: boolean; default: boolean; + // Distribution flag (ships:false = internal builds only) + settings-page group. + ships?: boolean; + group?: string; // "general" | "security" version?: string; installed_at?: string; } @@ -1066,6 +1069,13 @@ export async function getPersonas(): Promise { return (await res.json()).personas; } +/** Personas plus the build flag: `internal` builds may show unshipped coworkers + the Gallery. */ +export async function getPersonasIndex(): Promise<{ personas: Persona[]; internal: boolean }> { + const res = await fetch(`${httpBase()}/v1/personas`); + const body = await res.json(); + return { personas: body.personas ?? [], internal: !!body.internal }; +} + export async function updatePersona( id: string, body: { enabled?: boolean; surfaced?: boolean; default?: boolean }, @@ -1194,7 +1204,12 @@ export interface PersonaDetail { icon: string; tagline: string; description: string; + media: string[]; // bundle media/ screenshots, served via /v1/personas/{id}/media/{name} + builtin: boolean; + group: string; enabled: boolean; // persona on/off (shown in the picker) + surfaced: boolean; + default: boolean; tools: string[]; recommended_models: string[]; default_permission_mode: string; @@ -1203,6 +1218,15 @@ export interface PersonaDetail { default_connections: PersonaDefaultConnection[]; } +/** Fetch one bundle screenshot with launch auth and hand back an object URL. */ +export async function getPersonaMediaUrl(id: string, name: string): Promise { + const res = await fetch( + `${httpBase()}/v1/personas/${encodeURIComponent(id)}/media/${encodeURIComponent(name)}`, + ); + if (!res.ok) throw new Error(`media ${name}: ${res.status}`); + return URL.createObjectURL(await res.blob()); +} + export async function getPersonaDetail(id: string): Promise { const res = await fetch(`${httpBase()}/v1/personas/${encodeURIComponent(id)}`); return res.json(); diff --git a/surfaces/gui/src/components/PersonaView.test.tsx b/surfaces/gui/src/components/PersonaView.test.tsx index 85dee9a3..56e9b2a7 100644 --- a/surfaces/gui/src/components/PersonaView.test.tsx +++ b/surfaces/gui/src/components/PersonaView.test.tsx @@ -27,7 +27,12 @@ const DETAIL = { icon: "🛠️", tagline: "Operate and investigate", description: "A careful, methodical operations engineer.", + media: [], + builtin: true, + group: "general", enabled: true, + surfaced: true, + default: false, tools: ["files", "search", "shell"], recommended_models: ["claude-opus-4-8", "gpt-5.5"], default_permission_mode: "interactive", @@ -67,10 +72,12 @@ describe("PersonaView", () => { expect(await screen.findByText("Ops Coworker")).toBeTruthy(); expect(screen.getByText("Operate and investigate")).toBeTruthy(); expect(screen.getByText("A careful, methodical operations engineer.")).toBeTruthy(); - // tools rendered as chips - expect(screen.getByText("shell")).toBeTruthy(); - // a connected recommend shows "connected"; an unconnected one offers Connect/Add - expect(screen.getByText("connected")).toBeTruthy(); + // tool calls sit behind a collapsed Advanced disclosure (UX-035) + expect(screen.queryByText(/shell/)).toBeNull(); + fireEvent.click(screen.getByTestId("tool-calls-disclosure")); + expect(screen.getByText(/files · search · shell/)).toBeTruthy(); + // a connected row shows the Ready chip; unconnected ones offer Connect/Add + expect(screen.getAllByText(/Ready/).length).toBeGreaterThan(0); expect(screen.getByText("Connect")).toBeTruthy(); // datadog (core, not connected) expect(screen.getByText("Add")).toBeTruthy(); // filesystem (mcp, not connected) // defaults footer diff --git a/surfaces/gui/src/components/PersonaView.tsx b/surfaces/gui/src/components/PersonaView.tsx index 95528333..c4692a58 100644 --- a/surfaces/gui/src/components/PersonaView.tsx +++ b/surfaces/gui/src/components/PersonaView.tsx @@ -1,36 +1,44 @@ -// PersonaView — the persona detail page (§5, mock parity). Identity header + Enable toggle, About, -// Built-in capabilities (tools), "Connections for full benefit" (manifest `recommends`, core/optional -// + reason + connect state), "New sessions get by default" (persona-default connection toggles), and a -// defaults footer (recommended models / default mode / workspace). +// PersonaView — the persona detail page (UX-035 redesign). Identity header + Enable toggle; +// About as markdown with a screenshot carousel (bundle media/); ONE Connectors section with +// Status | Enable columns (replacing "Connections for full benefit" + "New sessions get by +// default" — they were the same list rendered twice); Tool calls as a collapsed disclosure +// under Advanced; a defaults footer; and the management group that moved off the list page +// (in picker, make default, export, delete). // -// Data: fetches GET /v1/personas/{id} on mount; also fetches /v1/connectors to thread real brand -// colors (Phase 1's `brand_color`) into the badges via visualFor(). Toggling a default connection -// POSTs /v1/personas/{id}/connections and applies the returned `default_connections` (re-read). -// Enabling/disabling POSTs /v1/personas/{id}/enable. +// Data: GET /v1/personas/{id} on mount; /v1/connectors threads real brand colors into the +// badges via visualFor(). Media loads through authenticated fetch → object URLs (a plain +// can't carry the sidecar token). import { useEffect, useState } from "react"; import { + deletePersona, + exportPersona, getConnectors, getPersonaDetail, + getPersonaMediaUrl, setPersonaConnection, setPersonaEnabled, + updatePersona, type PersonaDetail, } from "../api"; +import { chooseFolder } from "../tauri"; import { ConnectorBadge } from "../connectors/ConnectorIcon"; -import { fullPersonaName, shortPersonaName } from "../personaScope"; +import { fullPersonaName } from "../personaScope"; import { Icon } from "./Icon"; -import { PersonaGlyph } from "./personaIcon"; +import { Markdown } from "./Markdown"; import { Toggle } from "./Toggle"; import { indexConnectors, labelFor, visualFor, type ConnectorMap } from "../connectors/visuals"; -// Shared section-heading + tag + button utility strings (mock parity). const SEC_H = "text-[11px] uppercase tracking-[0.05em] text-faint font-semibold"; const TAG_CORE = "text-[10px] px-1.5 py-0.5 rounded-full bg-warnSoft/70 text-warnInk border border-warnInk/15"; const TAG_MCP = "text-[10px] px-1.5 py-0.5 rounded border border-line text-faint"; const BTN_ACCENT = "text-[12px] px-2.5 py-1.5 rounded-lg bg-accent text-white shrink-0"; const BTN_BORDERED = - "text-[12px] px-2.5 py-1.5 rounded-lg border border-line bg-paper hover:border-lineStrong shrink-0"; + "text-[12px] px-2.5 py-1.5 rounded-lg border border-line bg-paper hover:border-lineStrong shrink-0 disabled:opacity-40"; +const GRP = "rounded-xl2 border border-line bg-panel divide-y divide-line overflow-hidden"; +const COL_STATUS = "w-[96px] flex justify-end items-center shrink-0"; +const COL_ENABLE = "w-[64px] flex justify-center items-center shrink-0"; export function PersonaView({ personaId, @@ -44,19 +52,36 @@ export function PersonaView({ const [detail, setDetail] = useState(null); const [byName, setByName] = useState({}); const [error, setError] = useState(null); + const [mediaUrls, setMediaUrls] = useState([]); + const [shot, setShot] = useState(0); + const [showTools, setShowTools] = useState(false); + const [confirmDel, setConfirmDel] = useState(false); + const [msg, setMsg] = useState(null); useEffect(() => { let live = true; + let urls: string[] = []; setDetail(null); setError(null); + setMediaUrls([]); + setShot(0); getPersonaDetail(personaId) - .then((d) => live && setDetail(d)) + .then(async (d) => { + if (!live) return; + setDetail(d); + const loaded = await Promise.all( + (d.media || []).map((name) => getPersonaMediaUrl(personaId, name).catch(() => null)), + ); + urls = loaded.filter(Boolean) as string[]; + if (live) setMediaUrls(urls); + }) .catch(() => live && setError("Could not load this coworker.")); getConnectors() .then((list) => live && setByName(indexConnectors(list))) .catch(() => {}); return () => { live = false; + urls.forEach((u) => URL.revokeObjectURL(u)); }; }, [personaId]); @@ -75,6 +100,18 @@ export function PersonaView({ } }; + const patch = async (body: { surfaced?: boolean; default?: boolean }) => { + await updatePersona(personaId, body); + getPersonaDetail(personaId).then(setDetail).catch(() => {}); + }; + + const exportBundle = async () => { + const dir = await chooseFolder(); + if (!dir) return; + const r = await exportPersona(personaId, dir); + setMsg(r.ok ? `Exported to ${r.path}` : r.error || "export failed"); + }; + const header = (
{onBack && ( @@ -101,19 +138,55 @@ export function PersonaView({ ); } + // One Connectors table: manifest recommends ∪ the persona-default rows. A ref present in + // both renders once — status from the connect state, toggle from the default state. + const defaultsByRef = new Map(detail.default_connections.map((c) => [c.connector, c])); + const rows: { + key: string; + kind: string; + ref: string; + reason: string; + tier: string; + connected: boolean; + dflt?: { enabled: boolean; connected: boolean }; + }[] = detail.recommends.map((r) => ({ + key: `${r.kind}:${r.ref}`, + kind: r.kind, + ref: r.ref, + reason: r.reason, + tier: r.tier, + connected: r.connected, + dflt: r.kind === "connector" ? defaultsByRef.get(r.ref) : undefined, + })); + for (const c of detail.default_connections) { + if (!rows.some((r) => r.kind === "connector" && r.ref === c.connector)) { + rows.push({ + key: `connector:${c.connector}`, + kind: "connector", + ref: c.connector, + reason: "", + tier: "optional", + connected: c.connected, + dflt: c, + }); + } + } + return (
{header}
- {/* identity + enable */} + {/* identity + enable (no coworker glyph — owner 2026-08-21) */}
- - -

{fullPersonaName(detail.name, personaId)} + {detail.default && ( + + ★ + + )}

{detail.tagline}

@@ -123,113 +196,150 @@ export function PersonaView({
- {/* about */} - {detail.description && ( + {/* about: bundle markdown + screenshot carousel */} + {(detail.description || mediaUrls.length > 0) && (
About
-

{detail.description}

+ {detail.description && ( +
+ +
+ )} + {mediaUrls.length > 0 && ( +
+
+ {mediaUrls.length > 1 && ( + + )} + {`${detail.name} + {mediaUrls.length > 1 && ( + + )} +
+ {mediaUrls.length > 1 && ( +
+ {mediaUrls.map((_, i) => ( +
+ )} +
+ )}
)} - {/* tools */} - {detail.tools.length > 0 && ( + {/* connectors — one table, Status | Enable columns */} + {rows.length > 0 && (
-
Built-in capabilities
-
- {detail.tools.map((t) => ( - - {t} - - ))} +
+ Connectors + + Status + Enable +
-
- )} - - {/* connections for full benefit (manifest recommends) */} - {detail.recommends.length > 0 && ( -
-
Connections for full benefit
-

- Declared by the coworker — wire {shortPersonaName(detail.name, personaId)} into these - to unlock its full workflow. -

-
- {detail.recommends.map((r, i) => { - const isMcp = r.kind === "mcp"; - return ( -
0 ? " border-t border-line" : "") - } - key={`${r.kind}:${r.ref}`} - > - -
-
- {labelFor(r.ref, byName)} - {isMcp ? ( - MCP - ) : r.tier === "core" ? ( - core - ) : null} -
-
{r.reason}
+
+ {rows.map((r) => ( +
+ +
+
+ {labelFor(r.ref, byName)} + {r.kind === "mcp" ? ( + MCP + ) : r.tier === "core" ? ( + core + ) : null}
+ {r.reason &&
{r.reason}
} +
+ {r.connected ? ( - - - connected + + ● Ready ) : ( )} -
- ); - })} + + + {r.dflt ? ( + toggleDefault(r.ref, next)} + title={ + r.connected + ? "On by default for new sessions" + : "Connect this first" + } + /> + ) : ( + + )} + +
+ ))}
+

+ Enabled connectors are on when a new session starts — you can still mute any of + them per session. +

)} - {/* persona-default connections (persona → session default) */} - {detail.default_connections.length > 0 && ( + {/* advanced: tool calls, collapsed by default (everyday users don't need these) */} + {detail.tools.length > 0 && (
-
New sessions get by default
-

- When you start a {shortPersonaName(detail.name, personaId)} session these are enabled - automatically. You can still mute any of them per session. -

-
- {detail.default_connections.map((c) => ( -
- -
- {labelFor(c.connector, byName)} - {!c.connected && ( - · connect to enable - )} -
- toggleDefault(c.connector, next)} - title={c.connected ? "On by default for new sessions" : "Connect this first"} - /> +
Advanced
+
+ + {showTools && ( +
+ {detail.tools.join(" · ")}
- ))} + )}
)} @@ -258,6 +368,61 @@ export function PersonaView({
)} + + {/* management — the controls that left the list page (UX-035) */} +
+ + + {!detail.builtin && ( + + )} + {!detail.builtin && + (confirmDel ? ( + + + + + ) : ( + + ))} + {msg && {msg}} +
diff --git a/surfaces/gui/src/components/PersonasTab.tsx b/surfaces/gui/src/components/PersonasTab.tsx index 5ae29d8b..1d2b9df7 100644 --- a/surfaces/gui/src/components/PersonasTab.tsx +++ b/surfaces/gui/src/components/PersonasTab.tsx @@ -1,8 +1,6 @@ import { useEffect, useRef, useState } from "react"; import { - deletePersona, - exportPersona, - getPersonas, + getPersonasIndex, getSessions, installPersona, updatePersona, @@ -12,14 +10,13 @@ import { import { chooseFolder } from "../tauri"; import type { SessionInfo } from "../types"; import { Icon } from "./Icon"; +import { Toggle } from "./Toggle"; -// Personas management: enable a persona, choose whether it shows in the new-session picker, -// set the default, and install more from a local directory or a GitHub repo (snapshotted). -// Re-skinned to the mock's Tailwind card idiom (§ Settings-as-page); the page title supplies the -// heading, so this drops its own "Personas" sub-header. +// Personas management (UX-035): grouped General/Security lists with ONE toggle per row +// (enable implies picker); in-picker nuance, set-default, export and delete live on the +// per-coworker detail page. Unshipped coworkers (ships:false) and the installer are quiet +// text disclosures at the bottom; Folder/Zip install through native pickers. const CARD = "rounded-xl2 border border-line bg-panel"; -const SEC_H = "text-[11px] uppercase tracking-[0.05em] text-faint font-semibold"; -const CHECK = "flex items-center gap-1.5 text-[12.5px] text-muted select-none shrink-0"; const SELECT = "px-2.5 py-2 rounded-lg border border-line bg-paper text-[13px] text-ink shrink-0"; const INPUT = "flex-1 min-w-0 px-3 py-2 rounded-lg border border-line bg-paper text-[13px] text-ink outline-none focus:border-accent"; @@ -27,14 +24,26 @@ const BTN_ACCENT = "text-[12.5px] px-3 py-2 rounded-lg bg-accent text-white shri const BTN_BORDERED = "text-[12.5px] px-2.5 py-1.5 rounded-lg border border-line bg-paper hover:border-lineStrong shrink-0 disabled:opacity-40 disabled:hover:border-line"; -export function PersonasTab({ onOpenPersona }: { onOpenPersona?: (id: string) => void }) { +const QUIET_ROW = + "w-full flex items-center gap-2 px-4 pt-2 mt-6 text-[12.5px] text-muted select-none"; + +export function PersonasTab({ + onOpenPersona, + onMeta, +}: { + onOpenPersona?: (id: string) => void; + // Lets the section gate internal-build affordances (the Gallery entry point). + onMeta?: (meta: { internal: boolean }) => void; +}) { const [personas, setPersonas] = useState([]); + const [internal, setInternal] = useState(false); const [mode, setMode] = useState<"git" | "dir" | "zip">("git"); const [src, setSrc] = useState(""); const [busy, setBusy] = useState(false); const [msg, setMsg] = useState(null); const [consent, setConsent] = useState(null); - const [confirmDel, setConfirmDel] = useState(null); + const [showUnshipped, setShowUnshipped] = useState(false); + const [showInstall, setShowInstall] = useState(false); // Disabling archives the persona's conversations (server-side), so when there are any we // arm an inline confirm (same two-step idiom as delete) instead of flipping immediately. const [confirmOff, setConfirmOff] = useState(null); @@ -43,12 +52,25 @@ export function PersonasTab({ onOpenPersona }: { onOpenPersona?: (id: string) => // front and center (sharing v1). const addRef = useRef(null); useEffect(() => { - const focus = () => addRef.current?.scrollIntoView({ behavior: "smooth", block: "center" }); + const focus = () => { + setShowInstall(true); // the installer is a collapsed disclosure — open it first + setTimeout( + () => addRef.current?.scrollIntoView({ behavior: "smooth", block: "center" }), + 0, + ); + }; window.addEventListener("ocw-focus-import", focus); return () => window.removeEventListener("ocw-focus-import", focus); }, []); - const reload = () => getPersonas().then(setPersonas).catch(() => {}); + const reload = () => + getPersonasIndex() + .then((r) => { + setPersonas(r.personas); + setInternal(r.internal); + onMeta?.({ internal: r.internal }); + }) + .catch(() => {}); const reloadSessions = () => getSessions().then(setSessions).catch(() => {}); useEffect(() => { reload(); @@ -74,17 +96,6 @@ export function PersonasTab({ onOpenPersona }: { onOpenPersona?: (id: string) => else toggle(p.id, { enabled: false }); }; - const remove = async (id: string) => { - setConfirmDel(null); - const r = await deletePersona(id); - if (!r.ok) { - setMsg(r.error || "delete failed"); - return; - } - if (r.personas) setPersonas(r.personas); - else reload(); - }; - const finishInstall = (r: Awaited>) => { setBusy(false); if (!r.ok) { @@ -97,6 +108,16 @@ export function PersonasTab({ onOpenPersona }: { onOpenPersona?: (id: string) => setSrc(""); }; + // Folder installs go through the native picker — no path typing (owner, 2026-08-21). + const installDir = async () => { + const dir = await chooseFolder(); + if (!dir) return; + setBusy(true); + setMsg(null); + setConsent(null); + finishInstall(await installPersona({ dir })); + }; + const installZip = async (file: File) => { setBusy(true); setMsg(null); @@ -108,21 +129,12 @@ export function PersonasTab({ onOpenPersona }: { onOpenPersona?: (id: string) => finishInstall(await installPersona({ zip_b64: btoa(bin), filename: file.name })); }; - const exportOne = async (p: Persona) => { - const dir = await chooseFolder(); - if (!dir) return; - const r = await exportPersona(p.id, dir); - setMsg(r.ok ? `Exported to ${r.path}` : r.error || "export failed"); - }; - const install = async () => { if (!src.trim()) return; setBusy(true); setMsg(null); setConsent(null); - const r = await installPersona( - mode === "git" ? { git_url: src.trim() } : { dir: src.trim() }, - ); + const r = await installPersona({ git_url: src.trim() }); setBusy(false); if (!r.ok) { setMsg(r.error || "install failed"); @@ -134,171 +146,192 @@ export function PersonasTab({ onOpenPersona }: { onOpenPersona?: (id: string) => setSrc(""); }; + const unshipped = personas.filter((p) => p.ships === false); + + const group = (title: string | null, list: Persona[]) => { + if (list.length === 0) return null; + return ( +
+ {title && ( +
+ {title} +
+ )} +
+ {list.map((p) => ( +
+
+
+
+ {p.name} + {p.default && ( + + )} +
+
{p.tagline}
+
+ + next ? toggle(p.id, { enabled: true }) : requestDisable(p) + } + title={p.enabled ? "Disable this coworker" : "Enable this coworker"} + /> + {onOpenPersona && ( + + )} +
+ {confirmOff === p.id && ( +
+ + Disabling archives its {liveCount(p.id)} conversation + {liveCount(p.id) === 1 ? "" : "s"} — they stay available under “Show + archived”. + + + +
+ )} +
+ ))} +
+
+ ); + }; + return (
- {/* No intro line here — the PanelHead above already explains the page - (the two stacked one-liners read as duplicates, owner 2026-08-21). */} -
- {personas.map((p) => ( -
-
-
-
- {p.name} - {p.default && } - {p.builtin && · built-in} -
-
{p.tagline}
-
- - - + {showUnshipped && group(null, unshipped)} + + )} + + + {showInstall && ( +
+
+ + {mode === "git" ? ( + <> + setSrc(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && install()} + /> + - ))} -
- {confirmOff === p.id && ( -
- - Disabling archives its {liveCount(p.id)} conversation - {liveCount(p.id) === 1 ? "" : "s"} — they stay available under “Show - archived”. - + + ) : mode === "dir" ? ( + <> + + Opens the system folder picker; installs on selection. + + + ) : ( +
+ /> + )}
- ))} -
- -
Add coworkers
-

- Load from a local directory or a public GitHub repo. Files are copied into a managed area (a - snapshot), so the coworker stays stable even if the source changes. No code runs — a coworker only - composes vetted tools. -

-
- - {mode === "zip" ? ( - - ) : ( - <> - setSrc(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && install()} - /> - - - )} -
+
+ + + Only install coworkers from sources you trust and can hold accountable — a + coworker runs with access to your system. Files are snapshotted into a managed + area; no third-party code runs, but the instructions steer the coworker. Best + used by teams whose lead builds and distributes coworkers through official + channels. + +
+
+ )} {msg &&
{msg}
} {consent && consent.length > 0 && ( diff --git a/surfaces/gui/src/components/SettingsView.tsx b/surfaces/gui/src/components/SettingsView.tsx index ea1b1c56..f5c92af2 100644 --- a/surfaces/gui/src/components/SettingsView.tsx +++ b/surfaces/gui/src/components/SettingsView.tsx @@ -378,14 +378,25 @@ function VoiceInputSection() { function PersonasSection({ onOpenPersona }: { onOpenPersona?: (id: string) => void }) { const [galleryBump, setGalleryBump] = useState(0); const [galleryOpen, setGalleryOpen] = useState(false); + // The Gallery is OUR distribution channel during development — internal builds only + // (owner 2026-08-21). Users install from GitHub / folder / zip. + const [internal, setInternal] = useState(false); return (
- +

+ Coworkers are agents specialized for a particular role or task. They come equipped + with the tools and skills to be successful in that role. Enabling a coworker lets + you pick it when starting a conversation — the starred one is the default for new + sessions. +

+ setInternal(m.internal)} /> - + {internal && ( + )} {galleryOpen && ( setGalleryOpen(false)}