diff --git a/packages/web/src/components/nav.test.tsx b/packages/web/src/components/nav.test.tsx index 6d417a12..2cb2b616 100644 --- a/packages/web/src/components/nav.test.tsx +++ b/packages/web/src/components/nav.test.tsx @@ -19,6 +19,7 @@ afterEach(() => { function unreadOf(ids: string[]): Unread { return { unreadIds: new Set(ids), + answers: null, setViewing: () => undefined, }; } diff --git a/packages/web/src/components/unread-provider.tsx b/packages/web/src/components/unread-provider.tsx index fda2b1e1..fd360853 100644 --- a/packages/web/src/components/unread-provider.tsx +++ b/packages/web/src/components/unread-provider.tsx @@ -231,6 +231,6 @@ export function UnreadProvider({children}: {children: ReactNode}) { [], ); - const value = useMemo(() => ({unreadIds, setViewing}), [unreadIds]); + const value = useMemo(() => ({unreadIds, answers, setViewing}), [unreadIds, answers]); return {children}; } diff --git a/packages/web/src/hooks/use-unread.ts b/packages/web/src/hooks/use-unread.ts index e326fc64..9b278e25 100644 --- a/packages/web/src/hooks/use-unread.ts +++ b/packages/web/src/hooks/use-unread.ts @@ -1,3 +1,4 @@ +import type {ChatAnswer} from '@proxy/contracts/harness'; import {createContext, useContext} from 'react'; /** The conversation the Chat page shows, and the persona's last answer in it, if any. */ @@ -10,6 +11,8 @@ export type Viewing = {personaId: string; answerId: string | null}; export type Unread = { // The personas with something unread, the one the person is looking at left out. unreadIds: ReadonlySet; + // Each conversation's last answer, as the provider last asked; null before it first has. + answers: readonly ChatAnswer[] | null; // Which conversation the Chat page shows, and up to which answer; null elsewhere. What it shows // while the person looks at it is read. setViewing: (viewing: Viewing | null) => void; @@ -17,6 +20,7 @@ export type Unread = { const NOTHING: Unread = { unreadIds: new Set(), + answers: null, setViewing: () => undefined, }; diff --git a/packages/web/src/pages/chat-page.test.tsx b/packages/web/src/pages/chat-page.test.tsx index c03a7d58..615278a4 100644 --- a/packages/web/src/pages/chat-page.test.tsx +++ b/packages/web/src/pages/chat-page.test.tsx @@ -55,7 +55,12 @@ afterEach(() => { function renderChatPage({ personas = [general, finance], entries = [], -}: {personas?: ChatPersona[]; entries?: ThreadLog['entries']} = {}) { + listPersonas = () => personas, +}: { + personas?: ChatPersona[]; + entries?: ThreadLog['entries']; + listPersonas?: () => ChatPersona[]; +} = {}) { const router = createMemoryRouter( [ { @@ -71,7 +76,7 @@ function renderChatPage({ entries, persona: personas.find((candidate) => candidate.id === params.id) ?? general, }, - personas, + personas: listPersonas(), }), element: , }, @@ -243,6 +248,50 @@ describe('ChatPage', () => { expect(within(generalRow).queryByRole('status', {name: 'Unread'})).toBeNull(); }); + test("shows another agent's new answer in its row, not what was said before it", async () => { + const said = (text: string) => ({ + ...finance, + lastMessage: {kind: 'assistant' as const, text, at: '2026-10-21T10:00:00Z'}, + }); + let listed = [general, said('Old news.')]; + // The provider asks again once the open conversation is read: by then Finance has answered. + fetchMock.mockImplementation((input) => { + if (input.endsWith('/read')) { + answers = [ + { + personaId: 'finance', + name: 'Finance', + answer: {id: 'finance-new', text: 'New news.'}, + unread: true, + }, + ]; + listed = [general, said('New news.')]; + } + return Promise.resolve( + input.endsWith('/chat/answers') + ? Response.json({answers}) + : new Response(null, {status: 204}), + ); + }); + renderChatPage({ + entries: [ + {id: 'e1', kind: 'assistant', text: 'Hello', isError: false, at: '2026-10-21T10:00:01Z'}, + ], + listPersonas: () => listed, + }); + + const financeRow = await screen.findByRole('link', {name: /Finance/}); + expect(within(financeRow).getByText('Old news.')).toBeDefined(); + await waitFor(() => { + expect(within(financeRow).getByRole('status', {name: 'Unread'})).toBeDefined(); + }); + await waitFor(() => { + expect( + within(screen.getByRole('link', {name: /Finance/})).getByText('New news.'), + ).toBeDefined(); + }); + }); + test('reads the conversation up to the last answer it shows, what the person said after it aside', async () => { renderChatPage({ entries: [ diff --git a/packages/web/src/pages/chat-page.tsx b/packages/web/src/pages/chat-page.tsx index 6f560b62..5dfbfab7 100644 --- a/packages/web/src/pages/chat-page.tsx +++ b/packages/web/src/pages/chat-page.tsx @@ -378,7 +378,7 @@ function Conversation({ // The provider is told which conversation the page shows, and its last answer: while the person // looks at it, it is neither counted nor notified of, and is read up to that answer. const lastAnswerId = log.entries.findLast((entry) => entry.kind === 'assistant')?.id ?? null; - const {setViewing} = useUnread(); + const {setViewing, answers} = useUnread(); useEffect(() => { setViewing({personaId, answerId: lastAnswerId}); return () => { @@ -386,6 +386,30 @@ function Conversation({ }; }, [setViewing, personaId, lastAnswerId]); + // The list's previews are read with the page: when another conversation's last answer changes, + // as the provider asks every so often, the list is read again, so a row marked unread shows + // what came rather than what was said before it. The open one's row shows the log. + const answered = + answers === null + ? null + : answers + .filter((answer) => answer.personaId !== personaId) + .map((answer) => `${answer.personaId}:${answer.answer.id}`) + .toSorted() + .join(' '); + const listedAnswers = useRef(answered); + useEffect(() => { + if (answered === null || listedAnswers.current === answered) { + return; + } + const before = listedAnswers.current; + listedAnswers.current = answered; + if (before === null) { + return; + } + void revalidator.revalidate(); + }, [answered, revalidator]); + const send = async (event: FormEvent) => { event.preventDefault(); const message = text.trim();