From 1fe7c988c3ec829074abdf65b37888eacac98e6f Mon Sep 17 00:00:00 2001
From: arnestrickmann <115920878+arnestrickmann@users.noreply.github.com>
Date: Mon, 2 Nov 2026 13:59:19 -0800
Subject: [PATCH 1/2] add chat agent deletion menu
---
packages/web/src/pages/chat-page.test.tsx | 91 +++++++++++++++
packages/web/src/pages/chat-page.tsx | 135 +++++++++++++++-------
2 files changed, 185 insertions(+), 41 deletions(-)
create mode 100644 packages/web/src/pages/chat-page.test.tsx
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);
+ }}
+ />
From 7e11ac006436a934eb664911c50b4f469985d5a2 Mon Sep 17 00:00:00 2001
From: arnestrickmann <115920878+arnestrickmann@users.noreply.github.com>
Date: Mon, 2 Nov 2026 14:41:21 -0800
Subject: [PATCH 2/2] add agent chat rename action
---
packages/web/src/pages/chat-page.test.tsx | 30 ++++++++++++++++++
packages/web/src/pages/chat-page.tsx | 37 +++++++++++++++++------
2 files changed, 57 insertions(+), 10 deletions(-)
diff --git a/packages/web/src/pages/chat-page.test.tsx b/packages/web/src/pages/chat-page.test.tsx
index 1a067138..339f6a8b 100644
--- a/packages/web/src/pages/chat-page.test.tsx
+++ b/packages/web/src/pages/chat-page.test.tsx
@@ -63,6 +63,35 @@ function renderChatPage() {
}
describe('ChatPage', () => {
+ test('renames a changeable agent from its right-click menu', async () => {
+ fetchMock.mockImplementation((_input, init) => {
+ if (init?.method === 'PUT') {
+ return Promise.resolve(Response.json({persona: {...finance, name: 'Accounting'}}));
+ }
+ return Promise.resolve(new Response(null, {status: 204}));
+ });
+ renderChatPage();
+
+ fireEvent.contextMenu(await screen.findByRole('link', {name: /Finance/}));
+ fireEvent.click(await screen.findByRole('menuitem', {name: 'Rename'}));
+ expect(screen.getByRole('heading', {name: 'Edit agent'})).toBeDefined();
+ expect(
+ screen.getByText('Its name and its head. The conversation stays as it is.'),
+ ).toBeDefined();
+ fireEvent.change(screen.getByLabelText('Name'), {target: {value: 'Accounting'}});
+ fireEvent.click(screen.getByRole('button', {name: 'Save'}));
+
+ await waitFor(() => {
+ expect(fetchMock).toHaveBeenCalledWith(
+ '/api/orgs/acme/chat/personas/finance',
+ expect.objectContaining({
+ method: 'PUT',
+ body: JSON.stringify({name: 'Accounting', look, color: 'blue'}),
+ }),
+ );
+ });
+ });
+
test('deletes a changeable agent from its right-click menu after confirmation', async () => {
renderChatPage();
@@ -86,6 +115,7 @@ describe('ChatPage', () => {
fireEvent.contextMenu(await screen.findByRole('link', {name: /General/}));
+ expect(screen.queryByRole('menuitem', {name: 'Rename'})).toBeNull();
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 c9cfa1eb..cd5b5c6c 100644
--- a/packages/web/src/pages/chat-page.tsx
+++ b/packages/web/src/pages/chat-page.tsx
@@ -1,5 +1,12 @@
import type {ChatEntry, ChatPersona, Persona, ThreadLog} from '@proxy/contracts/harness';
-import {ArrowUpIcon, ChevronDownIcon, MoonIcon, PlusIcon, Trash2Icon} from 'lucide-react';
+import {
+ ArrowUpIcon,
+ ChevronDownIcon,
+ MoonIcon,
+ PencilIcon,
+ 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 {
@@ -123,12 +130,14 @@ function PersonaList({
current,
currentLog,
onNew,
+ onEdit,
onDelete,
}: {
personas: ChatPersona[];
current: string;
currentLog: ThreadLog;
onNew: () => void;
+ onEdit: (persona: ChatPersona) => void;
onDelete: (persona: ChatPersona) => void;
}) {
const org = useOrgSlug();
@@ -187,6 +196,10 @@ function PersonaList({
}>{row}
+ onEdit(persona)}>
+
+ Rename
+
onDelete(persona)}>
Delete
@@ -232,8 +245,8 @@ function Conversation({loaded}: {loaded: Loaded}) {
const [text, setText] = useState('');
const [sending, setSending] = useState(false);
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);
+ // The agent dialog: making a new one, or changing the selected one; closed as null.
+ const [editing, setEditing] = useState<'new' | ChatPersona | null>(null);
const [deleting, setDeleting] = useState(null);
const form = useRef(null);
const personaId = loaded.log.persona.id;
@@ -286,8 +299,11 @@ function Conversation({loaded}: {loaded: Loaded}) {
}
};
- // Saves the dialog's persona: a new one, opened; or this one, renamed or refaced.
+ // Saves the dialog's persona: a new one, opened; or the selected one, renamed or refaced.
const savePersona = async (persona: Persona): Promise => {
+ if (editing === null) {
+ return 'This agent is no longer being edited.';
+ }
if (editing === 'new') {
const made = await createPersona(org, persona);
if (made.isErr()) {
@@ -296,7 +312,7 @@ function Conversation({loaded}: {loaded: Loaded}) {
void navigate(`/${org}/chat/${made.value.persona.id}`);
return null;
}
- const changed = await updatePersona(org, personaId, persona);
+ const changed = await updatePersona(org, editing.id, persona);
if (changed.isErr()) {
return describeFetchError(changed.error);
}
@@ -335,7 +351,7 @@ function Conversation({loaded}: {loaded: Loaded}) {
current.mayChange ? (