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:
Rohit C Prasad
2026-08-20 18:17:10 -07:00
parent 19e10f0e76
commit 78b0f488c4
13 changed files with 597 additions and 327 deletions
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

+26 -7
View File
@@ -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();
+7 -5
View File
@@ -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();
});
+9 -5
View File
@@ -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();
});
+1 -1
View File
@@ -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 }) => {
+12 -8
View File
@@ -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();
});
+24
View File
@@ -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
+267 -102
View File
@@ -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>
+224 -191
View File
@@ -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 && (
+16 -4
View File
@@ -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)}