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 ? (