diff --git a/packages/web/src/pages/chat-page.test.tsx b/packages/web/src/pages/chat-page.test.tsx
new file mode 100644
index 00000000..1a067138
--- /dev/null
+++ b/packages/web/src/pages/chat-page.test.tsx
@@ -0,0 +1,91 @@
+///
+
+import type {ChatPersona, ThreadLog} from '@proxy/contracts/harness';
+import {ThemeProvider} from '@proxy/ui/components/theme-provider';
+import {TooltipProvider} from '@proxy/ui/components/tooltip';
+import {afterEach, beforeEach, describe, expect, mock, test} from 'bun:test';
+import {cleanup, fireEvent, render, screen, waitFor} from '@testing-library/react';
+import {createMemoryRouter, RouterProvider} from 'react-router';
+import {HumanSide} from '@/components/human-side';
+import {ChatPage} from '@/pages/chat-page';
+import {testHuman} from '@/test-org';
+
+const realFetch = globalThis.fetch;
+const fetchMock = mock<(input: string, init?: RequestInit) => Promise>();
+const look = {nose: 0.48, noseOut: 0.35, eye: 27, eyeTheta: 55, eyeLat: 8, eyes: 2 as const};
+
+function persona(id: string, name: string, mayChange: boolean): ChatPersona {
+ return {id, name, mayChange, look, color: 'blue', lastMessage: null};
+}
+
+const general = persona('general', 'General', false);
+const finance = persona('finance', 'Finance', true);
+const log: ThreadLog = {persona: general, entries: [], busy: false, sleepingUntil: null};
+
+beforeEach(() => {
+ fetchMock.mockReset();
+ fetchMock.mockImplementation(() => Promise.resolve(new Response(null, {status: 204})));
+ globalThis.fetch = ((input: string, init?: RequestInit) =>
+ fetchMock(input, init)) as typeof fetch;
+});
+
+afterEach(() => {
+ cleanup();
+ globalThis.fetch = realFetch;
+});
+
+function renderChatPage() {
+ const router = createMemoryRouter(
+ [
+ {
+ id: 'human',
+ loader: () => testHuman(),
+ element: ,
+ children: [
+ {
+ path: '/:org/chat/:id',
+ loader: () => ({log, personas: [general, finance]}),
+ element: ,
+ },
+ ],
+ },
+ ],
+ {initialEntries: ['/acme/chat/general']},
+ );
+
+ return render(
+
+
+
+
+ ,
+ );
+}
+
+describe('ChatPage', () => {
+ test('deletes a changeable agent from its right-click menu after confirmation', async () => {
+ renderChatPage();
+
+ fireEvent.contextMenu(await screen.findByRole('link', {name: /Finance/}));
+ fireEvent.click(await screen.findByRole('menuitem', {name: 'Delete'}));
+
+ expect(screen.getByRole('heading', {name: 'Delete Finance?'})).toBeDefined();
+ expect(screen.getByText(/every member's conversation/)).toBeDefined();
+ fireEvent.click(screen.getByRole('button', {name: 'Delete'}));
+
+ await waitFor(() => {
+ expect(fetchMock).toHaveBeenCalledWith(
+ '/api/orgs/acme/chat/personas/finance',
+ expect.objectContaining({method: 'DELETE'}),
+ );
+ });
+ });
+
+ test('does not offer deletion for a protected agent', async () => {
+ renderChatPage();
+
+ fireEvent.contextMenu(await screen.findByRole('link', {name: /General/}));
+
+ expect(screen.queryByRole('menuitem', {name: 'Delete'})).toBeNull();
+ });
+});
diff --git a/packages/web/src/pages/chat-page.tsx b/packages/web/src/pages/chat-page.tsx
index 26733089..c9cfa1eb 100644
--- a/packages/web/src/pages/chat-page.tsx
+++ b/packages/web/src/pages/chat-page.tsx
@@ -1,5 +1,5 @@
import type {ChatEntry, ChatPersona, Persona, ThreadLog} from '@proxy/contracts/harness';
-import {ArrowUpIcon, ChevronDownIcon, MoonIcon, PlusIcon} from 'lucide-react';
+import {ArrowUpIcon, ChevronDownIcon, MoonIcon, PlusIcon, Trash2Icon} from 'lucide-react';
import {type FormEvent, type KeyboardEvent, useCallback, useEffect, useRef, useState} from 'react';
import {Link, useLoaderData, useNavigate, useRevalidator} from 'react-router';
import {
@@ -10,6 +10,7 @@ import {
updatePersona,
} from '@/client/harness-client';
import {AppShell} from '@/components/app-shell';
+import {ConfirmDialog} from '@/components/confirm-dialog';
import {Head, LiveHead} from '@/components/head';
import {Markdown} from '@/components/markdown';
import {PersonaDialog} from '@/components/persona-dialog';
@@ -36,6 +37,12 @@ import {useOrgSlug} from '@/hooks/use-org';
import {formatAgo, formatDateTime} from '@/lib/format';
import {describeFetchError} from '@/lib/loader-utils';
import type {chatLoader} from '@/loaders';
+import {
+ ContextMenu,
+ ContextMenuContent,
+ ContextMenuItem,
+ ContextMenuTrigger,
+} from '@proxy/ui/components/context-menu';
// How often the log is read again while the agent is at work, and while it is not: a reply, or a
// wake-up, shows within a moment either way.
@@ -116,11 +123,13 @@ function PersonaList({
current,
currentLog,
onNew,
+ onDelete,
}: {
personas: ChatPersona[];
current: string;
currentLog: ThreadLog;
onNew: () => void;
+ onDelete: (persona: ChatPersona) => void;
}) {
const org = useOrgSlug();
const [watching, setWatching] = useState(false);
@@ -137,41 +146,53 @@ function PersonaList({
{personas.map((persona) => {
const last = persona.id === current ? lastOf(currentLog) : persona.lastMessage;
- return (
- -
-
- {persona.id === current ? (
-
- ) : (
-
- )}
-
-
-
- {persona.name}
-
- {last && (
-
- {formatAgo(last.at)}
-
- )}
+ const row = (
+
+ {persona.id === current ? (
+
+ ) : (
+
+ )}
+
+
+
+ {persona.name}
- {subtitle(last)}
+ {last && (
+
+ {formatAgo(last.at)}
+
+ )}
-
-
+ {subtitle(last)}
+
+
+ );
+ if (!persona.mayChange) {
+ return - {row}
;
+ }
+ return (
+
+ }>{row}
+
+ onDelete(persona)}>
+
+ Delete
+
+
+
);
})}
@@ -213,6 +234,7 @@ function Conversation({loaded}: {loaded: Loaded}) {
const [error, setError] = useState(null);
// The agent dialog: making a new one, or changing this one; closed as null.
const [editing, setEditing] = useState<'new' | 'current' | null>(null);
+ const [deleting, setDeleting] = useState(null);
const form = useRef(null);
const personaId = loaded.log.persona.id;
const current = loaded.log.persona;
@@ -282,14 +304,19 @@ function Conversation({loaded}: {loaded: Loaded}) {
return null;
};
- // Deletes this persona with everyone's conversations with it, and opens whichever is first.
- const removePersona = async (): Promise => {
- const removed = await deletePersona(org, personaId);
+ // Deletes a persona with everyone's conversations with it. If it was open, opens whichever is
+ // first; otherwise refreshes the list around the conversation that stays open.
+ const removePersona = async (persona: ChatPersona) => {
+ const removed = await deletePersona(org, persona.id);
if (removed.isErr()) {
- return describeFetchError(removed.error);
+ setError(describeFetchError(removed.error));
+ return;
}
- void navigate(`/${org}/chat`);
- return null;
+ if (persona.id === personaId) {
+ void navigate(`/${org}/chat`);
+ return;
+ }
+ await revalidator.revalidate();
};
// Turns in a row from the same side sit closer together than the sides do.
@@ -326,6 +353,7 @@ function Conversation({loaded}: {loaded: Loaded}) {
current={personaId}
currentLog={log}
onNew={() => setEditing('new')}
+ onDelete={setDeleting}
/>
}
panelLabel="Agents"
@@ -342,9 +370,34 @@ function Conversation({loaded}: {loaded: Loaded}) {
}
onClose={() => setEditing(null)}
onSave={savePersona}
- onDelete={editing === 'current' ? removePersona : undefined}
+ onDelete={
+ editing === 'current'
+ ? async () => {
+ setDeleting(current);
+ return null;
+ }
+ : undefined
+ }
/>
)}
+ setDeleting(null)}
+ onConfirm={() => {
+ if (!deleting) {
+ return;
+ }
+ void removePersona(deleting);
+ }}
+ />