Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions packages/web/src/pages/chat-page.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
/// <reference types="bun" />

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<Response>>();
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: <HumanSide />,
children: [
{
path: '/:org/chat/:id',
loader: () => ({log, personas: [general, finance]}),
element: <ChatPage />,
},
],
},
],
{initialEntries: ['/acme/chat/general']},
);

return render(
<ThemeProvider>
<TooltipProvider>
<RouterProvider router={router} />
</TooltipProvider>
</ThemeProvider>,
);
}

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();
});
});
135 changes: 94 additions & 41 deletions packages/web/src/pages/chat-page.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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';
Expand All @@ -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.
Expand Down Expand Up @@ -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);
Expand All @@ -137,41 +146,53 @@ function PersonaList({
<ul className="flex flex-col gap-0.5">
{personas.map((persona) => {
const last = persona.id === current ? lastOf(currentLog) : persona.lastMessage;
return (
<li key={persona.id}>
<Link
to={`/${org}/chat/${persona.id}`}
aria-current={persona.id === current ? 'page' : undefined}
className={cn(
'flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
persona.id === current && 'bg-sidebar-accent text-sidebar-accent-foreground',
)}
>
{persona.id === current ? (
<LiveHead
look={persona.look}
color={persona.color}
size={40}
watching={watching}
/>
) : (
<Head look={persona.look} color={persona.color} size={40} />
)}
<span className="flex min-w-0 flex-1 flex-col">
<span className="flex items-baseline gap-2">
<span className="min-w-0 flex-1 truncate text-sm font-medium">
{persona.name}
</span>
{last && (
<span className="shrink-0 text-xs text-muted-foreground">
{formatAgo(last.at)}
</span>
)}
const row = (
<Link
to={`/${org}/chat/${persona.id}`}
aria-current={persona.id === current ? 'page' : undefined}
className={cn(
'flex w-full items-center gap-3 rounded-lg px-2 py-2 text-left hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
persona.id === current && 'bg-sidebar-accent text-sidebar-accent-foreground',
)}
>
{persona.id === current ? (
<LiveHead
look={persona.look}
color={persona.color}
size={40}
watching={watching}
/>
) : (
<Head look={persona.look} color={persona.color} size={40} />
)}
<span className="flex min-w-0 flex-1 flex-col">
<span className="flex items-baseline gap-2">
<span className="min-w-0 flex-1 truncate text-sm font-medium">
{persona.name}
</span>
<span className="truncate text-xs text-muted-foreground">{subtitle(last)}</span>
{last && (
<span className="shrink-0 text-xs text-muted-foreground">
{formatAgo(last.at)}
</span>
)}
</span>
</Link>
</li>
<span className="truncate text-xs text-muted-foreground">{subtitle(last)}</span>
</span>
</Link>
);
if (!persona.mayChange) {
return <li key={persona.id}>{row}</li>;
}
return (
<ContextMenu key={persona.id}>
<ContextMenuTrigger render={<li />}>{row}</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem variant="destructive" onClick={() => onDelete(persona)}>
<Trash2Icon />
Delete
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
);
})}
</ul>
Expand Down Expand Up @@ -213,6 +234,7 @@ function Conversation({loaded}: {loaded: Loaded}) {
const [error, setError] = useState<string | null>(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<ChatPersona | null>(null);
const form = useRef<HTMLFormElement>(null);
const personaId = loaded.log.persona.id;
const current = loaded.log.persona;
Expand Down Expand Up @@ -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<string | null> => {
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.
Expand Down Expand Up @@ -326,6 +353,7 @@ function Conversation({loaded}: {loaded: Loaded}) {
current={personaId}
currentLog={log}
onNew={() => setEditing('new')}
onDelete={setDeleting}
/>
}
panelLabel="Agents"
Expand All @@ -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
}
/>
)}
<ConfirmDialog
open={deleting !== null}
title={deleting ? `Delete ${deleting.name}?` : ''}
description={
deleting
? `This deletes ${deleting.name} and every member's conversation with it. This cannot be undone.`
: ''
}
confirmLabel="Delete"
destructive
onClose={() => setDeleting(null)}
onConfirm={() => {
if (!deleting) {
return;
}
void removePersona(deleting);
}}
/>
<MessageScrollerProvider autoScroll defaultScrollPosition="end">
<MessageScroller className="min-h-0 flex-1">
<MessageScrollerViewport aria-label="Conversation">
Expand Down
Loading