mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-03 04:49:26 +00:00
Coworkers pages redesigned: grouped list, one toggle per row, detail overhaul
List groups General/Security with quiet disclosures for unshipped coworkers and the installer (native pickers for folder/zip); gallery is internal-build only. Detail: markdown About with bundle screenshot carousel, one Connectors table (Status/Enable), tool calls collapsed under Advanced, management controls moved here.
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 246 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 231 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 274 KiB |
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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<Persona[]> {
|
||||
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<string> {
|
||||
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<PersonaDetail> {
|
||||
const res = await fetch(`${httpBase()}/v1/personas/${encodeURIComponent(id)}`);
|
||||
return res.json();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
// <img src> 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<PersonaDetail | null>(null);
|
||||
const [byName, setByName] = useState<ConnectorMap>({});
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [mediaUrls, setMediaUrls] = useState<string[]>([]);
|
||||
const [shot, setShot] = useState(0);
|
||||
const [showTools, setShowTools] = useState(false);
|
||||
const [confirmDel, setConfirmDel] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(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 = (
|
||||
<div className="h-12 shrink-0 px-5 flex items-center gap-3 border-b border-line bg-paper">
|
||||
{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 (
|
||||
<main className="flex-1 min-w-0 flex flex-col bg-paper">
|
||||
{header}
|
||||
<div className="flex-1 overflow-y-auto hairline-scroll">
|
||||
<div className="max-w-3xl mx-auto px-7 py-6 space-y-6">
|
||||
{/* identity + enable */}
|
||||
{/* identity + enable (no coworker glyph — owner 2026-08-21) */}
|
||||
<header className="flex items-start gap-3.5">
|
||||
<span className="w-12 h-12 rounded-xl2 bg-panel border border-line grid place-items-center text-[22px]">
|
||||
<PersonaGlyph icon={detail.icon} size={22} />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-[20px] font-semibold tracking-tight">
|
||||
{fullPersonaName(detail.name, personaId)}
|
||||
{detail.default && (
|
||||
<span className="text-accent text-[15px] ml-1.5" title="Default for new sessions">
|
||||
★
|
||||
</span>
|
||||
)}
|
||||
</h1>
|
||||
<p className="text-[13px] text-muted mt-0.5">{detail.tagline}</p>
|
||||
</div>
|
||||
@@ -123,113 +196,150 @@ export function PersonaView({
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* about */}
|
||||
{detail.description && (
|
||||
{/* about: bundle markdown + screenshot carousel */}
|
||||
{(detail.description || mediaUrls.length > 0) && (
|
||||
<section>
|
||||
<div className={`${SEC_H} mb-1.5`}>About</div>
|
||||
<p className="text-[14px] leading-relaxed text-ink/90">{detail.description}</p>
|
||||
{detail.description && (
|
||||
<div className="text-[14px] leading-relaxed text-ink/90">
|
||||
<Markdown text={detail.description} />
|
||||
</div>
|
||||
)}
|
||||
{mediaUrls.length > 0 && (
|
||||
<div className="mt-3.5">
|
||||
<div className="flex items-center gap-2">
|
||||
{mediaUrls.length > 1 && (
|
||||
<button
|
||||
className="w-7 h-7 rounded-full border border-line bg-panel text-muted hover:text-ink hover:border-lineStrong shrink-0"
|
||||
aria-label="Previous screenshot"
|
||||
onClick={() => setShot((s) => (s - 1 + mediaUrls.length) % mediaUrls.length)}
|
||||
>
|
||||
‹
|
||||
</button>
|
||||
)}
|
||||
<img
|
||||
src={mediaUrls[shot]}
|
||||
alt={`${detail.name} screenshot ${shot + 1}`}
|
||||
className="flex-1 min-w-0 rounded-xl border border-line bg-panel"
|
||||
data-testid="persona-media"
|
||||
/>
|
||||
{mediaUrls.length > 1 && (
|
||||
<button
|
||||
className="w-7 h-7 rounded-full border border-line bg-panel text-muted hover:text-ink hover:border-lineStrong shrink-0"
|
||||
aria-label="Next screenshot"
|
||||
onClick={() => setShot((s) => (s + 1) % mediaUrls.length)}
|
||||
>
|
||||
›
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{mediaUrls.length > 1 && (
|
||||
<div className="flex justify-center gap-1.5 mt-2">
|
||||
{mediaUrls.map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
aria-label={`Screenshot ${i + 1}`}
|
||||
className={
|
||||
"w-1.5 h-1.5 rounded-full " + (i === shot ? "bg-accent" : "bg-lineStrong")
|
||||
}
|
||||
onClick={() => setShot(i)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* tools */}
|
||||
{detail.tools.length > 0 && (
|
||||
{/* connectors — one table, Status | Enable columns */}
|
||||
{rows.length > 0 && (
|
||||
<section>
|
||||
<div className={`${SEC_H} mb-2`}>Built-in capabilities</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{detail.tools.map((t) => (
|
||||
<span
|
||||
className="px-2 py-1 rounded-md bg-panel border border-line text-[12px] font-mono"
|
||||
key={t}
|
||||
>
|
||||
{t}
|
||||
</span>
|
||||
))}
|
||||
<div className={`${SEC_H} mb-1.5 flex items-baseline`}>
|
||||
<span>Connectors</span>
|
||||
<span className="ml-auto flex font-semibold text-[10.5px] text-faint normal-case tracking-normal">
|
||||
<span className={COL_STATUS}>Status</span>
|
||||
<span className={COL_ENABLE}>Enable</span>
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* connections for full benefit (manifest recommends) */}
|
||||
{detail.recommends.length > 0 && (
|
||||
<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 coworker — wire {shortPersonaName(detail.name, personaId)} into these
|
||||
to unlock its full workflow.
|
||||
</p>
|
||||
<div className="rounded-xl2 border border-line overflow-hidden">
|
||||
{detail.recommends.map((r, i) => {
|
||||
const isMcp = r.kind === "mcp";
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
"flex items-center gap-3 p-3 bg-panel" + (i > 0 ? " border-t border-line" : "")
|
||||
}
|
||||
key={`${r.kind}:${r.ref}`}
|
||||
>
|
||||
<ConnectorBadge connector={visualFor(r.ref, r.kind, byName)} size={32} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-medium">{labelFor(r.ref, byName)}</span>
|
||||
{isMcp ? (
|
||||
<span className={TAG_MCP}>MCP</span>
|
||||
) : r.tier === "core" ? (
|
||||
<span className={TAG_CORE}>core</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="text-[12px] text-muted">{r.reason}</div>
|
||||
<div className={GRP}>
|
||||
{rows.map((r) => (
|
||||
<div className="flex items-center gap-3 px-4 py-3" key={r.key}>
|
||||
<ConnectorBadge connector={visualFor(r.ref, r.kind, byName)} size={32} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-medium">{labelFor(r.ref, byName)}</span>
|
||||
{r.kind === "mcp" ? (
|
||||
<span className={TAG_MCP}>MCP</span>
|
||||
) : r.tier === "core" ? (
|
||||
<span className={TAG_CORE}>core</span>
|
||||
) : null}
|
||||
</div>
|
||||
{r.reason && <div className="text-[12px] text-muted">{r.reason}</div>}
|
||||
</div>
|
||||
<span className={COL_STATUS}>
|
||||
{r.connected ? (
|
||||
<span className="inline-flex items-center gap-1 text-[11.5px] text-ok shrink-0">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-ok" />
|
||||
connected
|
||||
<span className="text-[11px] font-medium px-2 py-0.5 rounded-full bg-okSoft text-ok border border-okLine">
|
||||
● Ready
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
className={r.tier === "core" && !isMcp ? BTN_ACCENT : BTN_BORDERED}
|
||||
className={r.tier === "core" && r.kind !== "mcp" ? BTN_ACCENT : BTN_BORDERED}
|
||||
onClick={onOpenIntegrations}
|
||||
>
|
||||
{isMcp ? "Add" : "Connect"}
|
||||
{r.kind === "mcp" ? "Add" : "Connect"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</span>
|
||||
<span className={COL_ENABLE}>
|
||||
{r.dflt ? (
|
||||
<Toggle
|
||||
checked={r.dflt.enabled}
|
||||
disabled={!r.connected}
|
||||
onChange={(next) => toggleDefault(r.ref, next)}
|
||||
title={
|
||||
r.connected
|
||||
? "On by default for new sessions"
|
||||
: "Connect this first"
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<span className="text-faint text-[11px]">—</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[12px] text-faint mt-1.5 px-1">
|
||||
Enabled connectors are on when a new session starts — you can still mute any of
|
||||
them per session.
|
||||
</p>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* 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 && (
|
||||
<section>
|
||||
<div className={`${SEC_H} mb-1`}>New sessions get by default</div>
|
||||
<p className="text-[12.5px] text-muted mb-2.5">
|
||||
When you start a {shortPersonaName(detail.name, personaId)} session these are enabled
|
||||
automatically. You can still mute any of them per session.
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{detail.default_connections.map((c) => (
|
||||
<div
|
||||
className={
|
||||
"flex items-center gap-3 p-2.5 rounded-xl2 border border-line bg-panel" +
|
||||
(c.connected ? "" : " opacity-50")
|
||||
}
|
||||
key={c.connector}
|
||||
>
|
||||
<ConnectorBadge connector={visualFor(c.connector, "connector", byName)} size={32} />
|
||||
<div className="flex-1 text-[13px] font-medium">
|
||||
{labelFor(c.connector, byName)}
|
||||
{!c.connected && (
|
||||
<span className="text-[11px] text-faint font-normal"> · connect to enable</span>
|
||||
)}
|
||||
</div>
|
||||
<Toggle
|
||||
checked={c.enabled}
|
||||
disabled={!c.connected}
|
||||
onChange={(next) => toggleDefault(c.connector, next)}
|
||||
title={c.connected ? "On by default for new sessions" : "Connect this first"}
|
||||
/>
|
||||
<div className={`${SEC_H} mb-1.5`}>Advanced</div>
|
||||
<div className="rounded-xl2 border border-line bg-panel">
|
||||
<button
|
||||
className="w-full flex items-center gap-2 px-4 py-2.5 text-left"
|
||||
data-testid="tool-calls-disclosure"
|
||||
onClick={() => setShowTools((v) => !v)}
|
||||
>
|
||||
<Icon
|
||||
name="chevronRight"
|
||||
size={12}
|
||||
className={"text-faint transition-transform" + (showTools ? " rotate-90" : "")}
|
||||
/>
|
||||
<span className="text-[13px]">Tool calls</span>
|
||||
<span className="ml-auto text-[12px] text-faint">{detail.tools.length}</span>
|
||||
</button>
|
||||
{showTools && (
|
||||
<div className="px-4 pb-3 font-mono text-[12px] text-muted">
|
||||
{detail.tools.join(" · ")}
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
@@ -258,6 +368,61 @@ export function PersonaView({
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* management — the controls that left the list page (UX-035) */}
|
||||
<section className="border-t border-line pt-4 flex flex-wrap items-center gap-x-5 gap-y-2 text-[12.5px]">
|
||||
<label className="flex items-center gap-2 text-muted select-none">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={detail.surfaced}
|
||||
disabled={!detail.enabled}
|
||||
data-testid="persona-surfaced"
|
||||
onChange={(e) => patch({ surfaced: e.target.checked })}
|
||||
/>
|
||||
Show in picker
|
||||
</label>
|
||||
<button
|
||||
className={BTN_BORDERED}
|
||||
disabled={detail.default || !detail.enabled}
|
||||
data-testid="persona-make-default"
|
||||
onClick={() => patch({ default: true })}
|
||||
>
|
||||
{detail.default ? "Default for new sessions" : "Make default"}
|
||||
</button>
|
||||
{!detail.builtin && (
|
||||
<button className={BTN_BORDERED} data-testid="persona-export" onClick={exportBundle}>
|
||||
Export…
|
||||
</button>
|
||||
)}
|
||||
{!detail.builtin &&
|
||||
(confirmDel ? (
|
||||
<span className="flex items-center gap-1.5">
|
||||
<button
|
||||
className="text-[12px] px-2.5 py-1.5 rounded-lg bg-danger text-white"
|
||||
data-testid="persona-delete-confirm"
|
||||
onClick={async () => {
|
||||
const r = await deletePersona(personaId);
|
||||
if (r.ok) onBack?.();
|
||||
else setMsg(r.error || "delete failed");
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button className={BTN_BORDERED} onClick={() => setConfirmDel(false)}>
|
||||
Keep
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
className="text-[12.5px] text-danger/80 hover:text-danger"
|
||||
data-testid="persona-delete"
|
||||
onClick={() => setConfirmDel(true)}
|
||||
>
|
||||
Delete…
|
||||
</button>
|
||||
))}
|
||||
{msg && <span className="text-muted">{msg}</span>}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -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<Persona[]>([]);
|
||||
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<string | null>(null);
|
||||
const [consent, setConsent] = useState<PersonaConsent[] | null>(null);
|
||||
const [confirmDel, setConfirmDel] = useState<string | null>(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<string | null>(null);
|
||||
@@ -43,12 +52,25 @@ export function PersonasTab({ onOpenPersona }: { onOpenPersona?: (id: string) =>
|
||||
// front and center (sharing v1).
|
||||
const addRef = useRef<HTMLDivElement | null>(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<ReturnType<typeof installPersona>>) => {
|
||||
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 (
|
||||
<div className={title ? "" : "mt-1.5"}>
|
||||
{title && (
|
||||
<div className="text-[12px] font-semibold text-muted px-4 mt-6 mb-1.5 first:mt-0">
|
||||
{title}
|
||||
</div>
|
||||
)}
|
||||
<div className={CARD + " divide-y divide-line"}>
|
||||
{list.map((p) => (
|
||||
<div key={p.id} className="px-[18px] py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[14px] font-medium flex items-center gap-1.5">
|
||||
<span className="truncate">{p.name}</span>
|
||||
{p.default && (
|
||||
<span className="text-accent" title="Default for new sessions">★</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-[12px] text-faint truncate mt-0.5">{p.tagline}</div>
|
||||
</div>
|
||||
<Toggle
|
||||
checked={p.enabled}
|
||||
onChange={(next) =>
|
||||
next ? toggle(p.id, { enabled: true }) : requestDisable(p)
|
||||
}
|
||||
title={p.enabled ? "Disable this coworker" : "Enable this coworker"}
|
||||
/>
|
||||
{onOpenPersona && (
|
||||
<button
|
||||
className="text-faint hover:text-ink shrink-0 p-1"
|
||||
title={`Configure ${p.name}`}
|
||||
aria-label={`Configure ${p.name}`}
|
||||
data-testid={`persona-configure-${p.id}`}
|
||||
onClick={() => onOpenPersona(p.id)}
|
||||
>
|
||||
<Icon name="sliders" size={15} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{confirmOff === p.id && (
|
||||
<div
|
||||
className="mt-2 flex items-center gap-2.5 text-[12px] text-muted"
|
||||
data-testid={`persona-disable-warning-${p.id}`}
|
||||
>
|
||||
<span className="min-w-0">
|
||||
Disabling archives its {liveCount(p.id)} conversation
|
||||
{liveCount(p.id) === 1 ? "" : "s"} — they stay available under “Show
|
||||
archived”.
|
||||
</span>
|
||||
<button
|
||||
className="text-[12px] px-2.5 py-1.5 rounded-lg bg-accent text-white shrink-0"
|
||||
data-testid={`persona-disable-confirm-${p.id}`}
|
||||
onClick={() => {
|
||||
setConfirmOff(null);
|
||||
toggle(p.id, { enabled: false });
|
||||
}}
|
||||
>
|
||||
Disable
|
||||
</button>
|
||||
<button className={BTN_BORDERED} onClick={() => setConfirmOff(null)}>
|
||||
Keep enabled
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* No intro line here — the PanelHead above already explains the page
|
||||
(the two stacked one-liners read as duplicates, owner 2026-08-21). */}
|
||||
<div className={CARD + " divide-y divide-line mb-6"}>
|
||||
{personas.map((p) => (
|
||||
<div key={p.id} className="px-4 py-3">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[13.5px] font-medium flex items-center gap-1.5">
|
||||
<span className="truncate">{p.name}</span>
|
||||
{p.default && <span className="text-accent" title="Default for new sessions">★</span>}
|
||||
{p.builtin && <span className="text-[11px] text-faint font-normal">· built-in</span>}
|
||||
</div>
|
||||
<div className="text-[12px] text-muted truncate">{p.tagline}</div>
|
||||
</div>
|
||||
<label className={CHECK}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={p.enabled}
|
||||
onChange={(e) =>
|
||||
e.target.checked ? toggle(p.id, { enabled: true }) : requestDisable(p)
|
||||
}
|
||||
/>
|
||||
Enabled
|
||||
</label>
|
||||
<label className={CHECK + (p.enabled ? "" : " opacity-40")}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={p.surfaced}
|
||||
disabled={!p.enabled}
|
||||
onChange={(e) => toggle(p.id, { surfaced: e.target.checked })}
|
||||
/>
|
||||
In picker
|
||||
</label>
|
||||
<button
|
||||
className={BTN_BORDERED}
|
||||
disabled={p.default || !p.enabled}
|
||||
onClick={() => toggle(p.id, { default: true })}
|
||||
{/* One toggle per row (enable implies picker); ★ marks the default. Everything
|
||||
else — in-picker nuance, default, export, delete — lives on the detail page. */}
|
||||
{group("General", personas.filter((p) => p.ships !== false && p.group !== "security"))}
|
||||
{group("Security", personas.filter((p) => p.ships !== false && p.group === "security"))}
|
||||
|
||||
{unshipped.length > 0 && (
|
||||
<>
|
||||
<button
|
||||
className={QUIET_ROW}
|
||||
data-testid="unshipped-disclosure"
|
||||
onClick={() => setShowUnshipped((v) => !v)}
|
||||
>
|
||||
<Icon
|
||||
name="chevronRight"
|
||||
size={12}
|
||||
className={"transition-transform" + (showUnshipped ? " rotate-90" : "")}
|
||||
/>
|
||||
<span>Not in this release · {unshipped.length} coworkers</span>
|
||||
<span className="ml-auto text-faint text-[12px]">
|
||||
{internal ? "internal build" : "not in this release"}
|
||||
</span>
|
||||
</button>
|
||||
{showUnshipped && group(null, unshipped)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
ref={addRef as any}
|
||||
className={QUIET_ROW}
|
||||
data-testid="install-disclosure"
|
||||
onClick={() => setShowInstall((v) => !v)}
|
||||
>
|
||||
<Icon
|
||||
name="chevronRight"
|
||||
size={12}
|
||||
className={"transition-transform" + (showInstall ? " rotate-90" : "")}
|
||||
/>
|
||||
<span>Install a coworker</span>
|
||||
<span className="ml-auto text-faint text-[12px]">GitHub · folder · .zip</span>
|
||||
</button>
|
||||
{showInstall && (
|
||||
<div className={CARD + " mt-1.5 p-4"}>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
className={SELECT}
|
||||
value={mode}
|
||||
onChange={(e) => setMode(e.target.value as "git" | "dir" | "zip")}
|
||||
>
|
||||
Set default
|
||||
</button>
|
||||
{onOpenPersona && (
|
||||
<button
|
||||
className="text-faint hover:text-ink shrink-0 p-1"
|
||||
title={`Configure ${p.name}`}
|
||||
aria-label={`Configure ${p.name}`}
|
||||
data-testid={`persona-configure-${p.id}`}
|
||||
onClick={() => onOpenPersona(p.id)}
|
||||
>
|
||||
<Icon name="sliders" size={15} />
|
||||
</button>
|
||||
)}
|
||||
{!p.builtin && (
|
||||
<button
|
||||
className={BTN_BORDERED}
|
||||
title="Export this coworker as a shareable bundle"
|
||||
data-testid={`persona-export-${p.id}`}
|
||||
onClick={() => void exportOne(p)}
|
||||
>
|
||||
Export…
|
||||
</button>
|
||||
)}
|
||||
{!p.builtin &&
|
||||
(confirmDel === p.id ? (
|
||||
<span className="flex items-center gap-1.5 shrink-0">
|
||||
<button
|
||||
className="text-[12px] px-2 py-1.5 rounded-lg bg-danger text-white"
|
||||
data-testid={`persona-delete-confirm-${p.id}`}
|
||||
onClick={() => remove(p.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
<button className={BTN_BORDERED} onClick={() => setConfirmDel(null)}>
|
||||
Keep
|
||||
</button>
|
||||
</span>
|
||||
) : (
|
||||
<button
|
||||
className="text-faint hover:text-danger shrink-0 p-1"
|
||||
title="Delete this coworker"
|
||||
aria-label={`Delete ${p.name}`}
|
||||
data-testid={`persona-delete-${p.id}`}
|
||||
onClick={() => setConfirmDel(p.id)}
|
||||
>
|
||||
<Icon name="trash" size={14} />
|
||||
<option value="git">GitHub URL</option>
|
||||
<option value="dir">Local folder</option>
|
||||
<option value="zip">Bundle zip</option>
|
||||
</select>
|
||||
{mode === "git" ? (
|
||||
<>
|
||||
<input
|
||||
className={INPUT}
|
||||
placeholder="https://github.com/acme/ops-coworker"
|
||||
value={src}
|
||||
onChange={(e) => setSrc(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && install()}
|
||||
/>
|
||||
<button className={BTN_ACCENT} disabled={busy || !src.trim()} onClick={install}>
|
||||
{busy ? "Installing…" : "Install"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{confirmOff === p.id && (
|
||||
<div
|
||||
className="mt-2 flex items-center gap-2.5 text-[12px] text-muted"
|
||||
data-testid={`persona-disable-warning-${p.id}`}
|
||||
>
|
||||
<span className="min-w-0">
|
||||
Disabling archives its {liveCount(p.id)} conversation
|
||||
{liveCount(p.id) === 1 ? "" : "s"} — they stay available under “Show
|
||||
archived”.
|
||||
</span>
|
||||
</>
|
||||
) : mode === "dir" ? (
|
||||
<>
|
||||
<button
|
||||
className="text-[12px] px-2.5 py-1.5 rounded-lg bg-accent text-white shrink-0"
|
||||
data-testid={`persona-disable-confirm-${p.id}`}
|
||||
onClick={() => {
|
||||
setConfirmOff(null);
|
||||
toggle(p.id, { enabled: false });
|
||||
className={BTN_BORDERED}
|
||||
disabled={busy}
|
||||
data-testid="persona-dir-choose"
|
||||
onClick={() => void installDir()}
|
||||
>
|
||||
{busy ? "Installing…" : "Choose folder…"}
|
||||
</button>
|
||||
<span className="text-[12px] text-faint">
|
||||
Opens the system folder picker; installs on selection.
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<label className={BTN_BORDERED + " cursor-pointer"}>
|
||||
{busy ? "Installing…" : "Choose a .zip bundle…"}
|
||||
<input
|
||||
type="file"
|
||||
accept=".zip"
|
||||
className="hidden"
|
||||
data-testid="persona-zip-input"
|
||||
disabled={busy}
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) void installZip(f);
|
||||
e.target.value = "";
|
||||
}}
|
||||
>
|
||||
Disable
|
||||
</button>
|
||||
<button className={BTN_BORDERED} onClick={() => setConfirmOff(null)}>
|
||||
Keep enabled
|
||||
</button>
|
||||
</div>
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div ref={addRef} 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 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">
|
||||
<select
|
||||
className={SELECT}
|
||||
value={mode}
|
||||
onChange={(e) => setMode(e.target.value as "git" | "dir" | "zip")}
|
||||
>
|
||||
<option value="git">GitHub URL</option>
|
||||
<option value="dir">Local directory</option>
|
||||
<option value="zip">Bundle zip</option>
|
||||
</select>
|
||||
{mode === "zip" ? (
|
||||
<label className={BTN_BORDERED + " cursor-pointer"}>
|
||||
{busy ? "Installing…" : "Choose a .zip bundle…"}
|
||||
<input
|
||||
type="file"
|
||||
accept=".zip"
|
||||
className="hidden"
|
||||
data-testid="persona-zip-input"
|
||||
disabled={busy}
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) void installZip(f);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
className={INPUT}
|
||||
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()}
|
||||
/>
|
||||
<button className={BTN_ACCENT} disabled={busy || !src.trim()} onClick={install}>
|
||||
{busy ? "Installing…" : "Install"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-start gap-2 mt-3 text-[12px] text-muted leading-relaxed">
|
||||
<span className="text-warnInk shrink-0">⚠</span>
|
||||
<span>
|
||||
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.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{msg && <div className="text-[12.5px] text-muted mt-2.5">{msg}</div>}
|
||||
|
||||
{consent && consent.length > 0 && (
|
||||
|
||||
@@ -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 (
|
||||
<section>
|
||||
<PanelHead
|
||||
title="Coworkers"
|
||||
sub="Which coworkers are enabled and shown in the picker — the starred one is the default for new sessions."
|
||||
<PanelHead title="Coworkers" sub="Manage your coworkers and add new ones." />
|
||||
<p className="text-[13px] text-muted leading-relaxed max-w-[560px] mt-5 mb-1">
|
||||
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.
|
||||
</p>
|
||||
<PersonasTab
|
||||
key={galleryBump}
|
||||
onOpenPersona={onOpenPersona}
|
||||
onMeta={(m) => setInternal(m.internal)}
|
||||
/>
|
||||
<PersonasTab key={galleryBump} onOpenPersona={onOpenPersona} />
|
||||
{internal && (
|
||||
<button
|
||||
className="mt-6 w-full rounded-xl2 border border-line bg-panel px-4 py-3.5 flex items-center gap-3 text-left hover:border-lineStrong"
|
||||
data-testid="gallery-link"
|
||||
@@ -400,6 +411,7 @@ function PersonasSection({ onOpenPersona }: { onOpenPersona?: (id: string) => vo
|
||||
</span>
|
||||
<span className="text-[12.5px] text-accent shrink-0">Open →</span>
|
||||
</button>
|
||||
)}
|
||||
{galleryOpen && (
|
||||
<GalleryModal
|
||||
onClose={() => setGalleryOpen(false)}
|
||||
|
||||
Reference in New Issue
Block a user