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
1 change: 1 addition & 0 deletions packages/web/src/components/nav.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ afterEach(() => {
function unreadOf(ids: string[]): Unread {
return {
unreadIds: new Set(ids),
answers: null,
setViewing: () => undefined,
};
}
Expand Down
2 changes: 1 addition & 1 deletion packages/web/src/components/unread-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,6 @@ export function UnreadProvider({children}: {children: ReactNode}) {
[],
);

const value = useMemo<Unread>(() => ({unreadIds, setViewing}), [unreadIds]);
const value = useMemo<Unread>(() => ({unreadIds, answers, setViewing}), [unreadIds, answers]);
return <UnreadContext.Provider value={value}>{children}</UnreadContext.Provider>;
}
4 changes: 4 additions & 0 deletions packages/web/src/hooks/use-unread.ts
Original file line number Diff line number Diff line change
@@ -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. */
Expand All @@ -10,13 +11,16 @@ 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<string>;
// 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;
};

const NOTHING: Unread = {
unreadIds: new Set(),
answers: null,
setViewing: () => undefined,
};

Expand Down
53 changes: 51 additions & 2 deletions packages/web/src/pages/chat-page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
[
{
Expand All @@ -71,7 +76,7 @@ function renderChatPage({
entries,
persona: personas.find((candidate) => candidate.id === params.id) ?? general,
},
personas,
personas: listPersonas(),
}),
element: <ChatPage />,
},
Expand Down Expand Up @@ -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: [
Expand Down
26 changes: 25 additions & 1 deletion packages/web/src/pages/chat-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -378,14 +378,38 @@ 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 () => {
setViewing(null);
};
}, [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();
Expand Down
Loading