From 8d270abc586823ca56e5ddd092004d956ed0fbc2 Mon Sep 17 00:00:00 2001 From: hkonsti <45428767+hkonsti@users.noreply.github.com> Date: Thu, 5 Nov 2026 02:40:06 +0000 Subject: [PATCH 1/4] mark what the agents said unread until it is seen, count it on Chat, and tell of it on the desktop A conversation keeps how far its member has read it (AgentThread.readSeq), moved on by the Chat page while it is in view and by what the person says. The persona list says which conversations have something unread; the sidebar's Chat counts them, as does the window's title and the desktop app's badge; and, turned on under Account, a notification tells of an answer that comes while the person is elsewhere, opening the conversation when clicked. --- packages/api/src/harness/fake-store.ts | 1 + packages/api/src/harness/route.test.ts | 34 +++- packages/api/src/harness/route.ts | 22 ++- packages/api/src/harness/store.ts | 4 +- packages/api/src/server/create-api-router.ts | 1 + packages/contracts/src/harness.ts | 9 +- .../v045_add_thread_read_cursor/migration.sql | 9 ++ packages/db/prisma/schema.prisma | 4 + packages/db/src/harness.test.ts | 43 ++++++ packages/db/src/harness.ts | 48 ++++-- packages/desktop/README.md | 6 + packages/desktop/src/channels.ts | 8 +- packages/desktop/src/ipc.ts | 9 ++ packages/desktop/src/main.ts | 19 ++- packages/desktop/src/notifications.ts | 31 ++++ packages/desktop/src/preload.ts | 20 ++- packages/desktop/src/updates.ts | 16 +- packages/docs/content/agents/chat.mdx | 8 + packages/web/src/client/harness-client.ts | 7 +- packages/web/src/components/human-side.tsx | 8 +- packages/web/src/components/nav.test.tsx | 31 +++- packages/web/src/components/nav.tsx | 21 +++ .../src/components/notification-setting.tsx | 40 +++++ .../src/components/unread-provider.test.tsx | 123 +++++++++++++++ .../web/src/components/unread-provider.tsx | 146 ++++++++++++++++++ packages/web/src/hooks/use-unread.ts | 34 ++++ packages/web/src/lib/desktop.ts | 7 +- packages/web/src/lib/notifications.test.ts | 99 ++++++++++++ packages/web/src/lib/notifications.ts | 78 ++++++++++ packages/web/src/pages/account-page.tsx | 22 ++- packages/web/src/pages/chat-page.test.tsx | 70 ++++++++- packages/web/src/pages/chat-page.tsx | 67 +++++++- 32 files changed, 984 insertions(+), 61 deletions(-) create mode 100644 packages/db/prisma/migrations/v045_add_thread_read_cursor/migration.sql create mode 100644 packages/desktop/src/ipc.ts create mode 100644 packages/desktop/src/notifications.ts create mode 100644 packages/web/src/components/notification-setting.tsx create mode 100644 packages/web/src/components/unread-provider.test.tsx create mode 100644 packages/web/src/components/unread-provider.tsx create mode 100644 packages/web/src/hooks/use-unread.ts create mode 100644 packages/web/src/lib/notifications.test.ts create mode 100644 packages/web/src/lib/notifications.ts diff --git a/packages/api/src/harness/fake-store.ts b/packages/api/src/harness/fake-store.ts index 697a14a0..dad0c51e 100644 --- a/packages/api/src/harness/fake-store.ts +++ b/packages/api/src/harness/fake-store.ts @@ -36,6 +36,7 @@ export function fakeStore(): FakeStore { listShownEntries: mock(), listOpenJobs: mock(), appendPersonMessage: mock(), + markThreadRead: mock(), completeSetAlert: mock(), deleteAlert: mock(), listThreadAlerts: mock(), diff --git a/packages/api/src/harness/route.test.ts b/packages/api/src/harness/route.test.ts index 002b3f5d..bd23033c 100644 --- a/packages/api/src/harness/route.test.ts +++ b/packages/api/src/harness/route.test.ts @@ -15,7 +15,7 @@ mock.module('./built-in-agent', () => ({ensureBuiltInAgent})); const now = new Date('2026-10-21T10:00:00Z'); const look = {nose: 0.48, noseOut: 0.35, eye: 27, eyeTheta: 55, eyeLat: 8, eyes: 2 as const}; const persona = {id: 'persona-1', name: 'Finance', look, color: 'blue', chatModel: 'claude' as 'claude' | 'glm', createdById: 'user-1'}; -const personaResponse = {id: 'persona-1', name: 'Finance', look, color: 'blue' as const, chatModel: null, mayChange: true, lastMessage: null}; +const personaResponse = {id: 'persona-1', name: 'Finance', look, color: 'blue' as const, chatModel: null, mayChange: true, lastMessage: null, unread: false}; const thread: ThreadRow = { id: 'thread-1', orgId: 'org-1', @@ -61,7 +61,7 @@ beforeEach(async () => { const {listPersonas, getPersona, createPersona, updatePersona, deletePersona, getMemberThread, ensureMemberThread, listShownEntries, listOpenJobs, appendPersonMessage} = store; deletePersona.mockResolvedValue(Ok(true)); requireViewer.mockResolvedValue(Ok(viewer)); - listPersonas.mockResolvedValue(Ok([{...persona, lastMessage: {kind: 'assistant', text: 'Your vault\n has one entry.', createdAt: now}}])); + listPersonas.mockResolvedValue(Ok([{...persona, lastMessage: {id: 'e9', kind: 'assistant', text: 'Your vault\n has one entry.', createdAt: now}, unread: false}])); getPersona.mockResolvedValue(Ok(persona)); createPersona.mockResolvedValue(Ok({...persona, id: 'persona-2'})); updatePersona.mockResolvedValue(Ok(true)); @@ -84,16 +84,22 @@ describe('the chat routes', () => { expect(store.appendPersonMessage).not.toHaveBeenCalled(); }); - test('list the organization’s personas, with their heads, whether the viewer may change each, and the last thing said with each, on one line', async () => { + test('list the organization’s personas, with their heads, whether the viewer may change each, the last thing said with each, on one line, and whether it is unread', async () => { const {handleListPersonasRoute} = routes; store.listPersonas.mockResolvedValue( Ok([ - {...persona, lastMessage: {kind: 'assistant', text: 'Your vault\n has one entry.', createdAt: now}}, - {...persona, id: 'persona-3', createdById: 'user-2', lastMessage: null}, + {...persona, lastMessage: {id: 'e9', kind: 'assistant', text: 'Your vault\n has one entry.', createdAt: now}, unread: true}, + {...persona, id: 'persona-3', createdById: 'user-2', lastMessage: null, unread: false}, { ...persona, id: 'persona-4', - lastMessage: {kind: 'assistant', text: '## Found\n\n- **Three** invoices, see [the list](https://x.test)\n\n| Who | Sum |\n| --- | --- |\n| Dana | 40 |\n\n```\ncode\n```', createdAt: now}, + lastMessage: { + id: 'e10', + kind: 'assistant', + text: '## Found\n\n- **Three** invoices, see [the list](https://x.test)\n\n| Who | Sum |\n| --- | --- |\n| Dana | 40 |\n\n```\ncode\n```', + createdAt: now, + }, + unread: false, }, ]), ); @@ -103,9 +109,9 @@ describe('the chat routes', () => { expect(store.listPersonas).toHaveBeenCalledWith('org-1', 'user-1'); expect(listed).toEqual({ personas: [ - {...personaResponse, lastMessage: {kind: 'assistant', text: 'Your vault has one entry.', at: '2026-10-21T10:00:00.000Z'}}, + {...personaResponse, lastMessage: {id: 'e9', kind: 'assistant', text: 'Your vault has one entry.', at: '2026-10-21T10:00:00.000Z'}, unread: true}, {...personaResponse, id: 'persona-3', mayChange: false}, - {...personaResponse, id: 'persona-4', lastMessage: {kind: 'assistant', text: 'Found Three invoices, see the list Who Sum Dana 40', at: '2026-10-21T10:00:00.000Z'}}, + {...personaResponse, id: 'persona-4', lastMessage: {id: 'e10', kind: 'assistant', text: 'Found Three invoices, see the list Who Sum Dana 40', at: '2026-10-21T10:00:00.000Z'}}, ], }); }); @@ -234,4 +240,16 @@ describe('the chat routes', () => { expect((await handlePostMessageRoute(request({personaId: 'persona-1'}, {text: ' '}))).unwrapErr().kind).toBe('parse_error'); expect((await handlePostMessageRoute(request({personaId: 'persona-1'}, {text: 'x'.repeat(20_001)}))).unwrapErr().kind).toBe('parse_error'); }); + + test('mark the viewer’s conversation with the persona read, begun where there was none, and say where it is gone', async () => { + const {handleMarkReadRoute} = routes; + store.markThreadRead.mockResolvedValue(Ok(true)); + + expect((await handleMarkReadRoute(request({personaId: 'persona-1'}))).isOk()).toBe(true); + expect(store.markThreadRead).toHaveBeenCalledWith('thread-1'); + + store.getPersona.mockResolvedValue(Ok(null)); + expect((await handleMarkReadRoute(request({personaId: 'persona-9'}))).unwrapErr().kind).toBe('not_found'); + expect(store.markThreadRead).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/api/src/harness/route.ts b/packages/api/src/harness/route.ts index e5467958..c776861c 100644 --- a/packages/api/src/harness/route.ts +++ b/packages/api/src/harness/route.ts @@ -42,6 +42,7 @@ export type ChatStore = Pick< | 'listShownEntries' | 'listOpenJobs' | 'appendPersonMessage' + | 'markThreadRead' >; // Whether the viewer may rename, reface, switch the model of or delete the persona: whoever made it, or the organization's owner (utils/manages.ts). @@ -49,7 +50,8 @@ function managesPersona(viewer: OrgViewer, persona: PersonaRow): boolean { return manages(viewer, {ownerUserId: null, byId: persona.createdById}); } -function toPersona(viewer: OrgViewer, persona: PersonaRow & {lastMessage?: LastMessage | null}): ChatPersona { +// A persona for the response; one made or changed just now has nothing said with it, and nothing unread. +function toPersona(viewer: OrgViewer, persona: PersonaRow & {lastMessage?: LastMessage | null; unread?: boolean}): ChatPersona { const last = persona.lastMessage ?? null; return { id: persona.id, @@ -58,7 +60,8 @@ function toPersona(viewer: OrgViewer, persona: PersonaRow & {lastMessage?: LastM color: toAvatarColor(persona.color), chatModel: viewer.insider ? persona.chatModel : null, mayChange: managesPersona(viewer, persona), - lastMessage: last && {kind: last.kind, text: preview(last.text), at: last.createdAt.toISOString()}, + lastMessage: last && {id: last.id, kind: last.kind, text: preview(last.text), at: last.createdAt.toISOString()}, + unread: persona.unread ?? false, }; } @@ -120,7 +123,7 @@ function requireThread(store: ChatStore, viewer: OrgViewer, persona: PersonaRow) }); } -/** `GET /api/orgs/:org/chat/personas`: the organization's personas, in the order they were made, with the viewer's conversations; a General one is made where it has none. */ +/** `GET /api/orgs/:org/chat/personas`: the organization's personas, in the order they were made, with the viewer's conversations, each with whether the persona said something the viewer hasn't seen; a General one is made where it has none. */ function handleListPersonasRoute(store: ChatStore, request: AuthenticatedRequest): Promise, ApiError>> { return Do(async ($) => { const viewer = $(await requireChat(request)); @@ -198,6 +201,18 @@ function handlePostMessageRoute(store: ChatStore, request: AuthenticatedRequest) }); } +/** `POST /api/orgs/:org/chat/personas/:personaId/read`: the viewer has seen their conversation with the persona as it is now; what the persona says next is unread again. */ +function handleMarkReadRoute(store: ChatStore, request: AuthenticatedRequest): Promise> { + return Do(async ($) => { + const viewer = $(await requireChat(request)); + const persona = $(await requirePersona(store, viewer, request)); + const thread = $(await requireThread(store, viewer, persona)); + if (!$(await store.markThreadRead(thread.id))) { + return $(Err(ApiErr.notFound('thread', thread.id))); + } + }); +} + /** The Chat page's routes, on the store. */ export function chatRoutes(store: ChatStore) { return { @@ -207,5 +222,6 @@ export function chatRoutes(store: ChatStore) { handleDeletePersonaRoute: (request: AuthenticatedRequest) => handleDeletePersonaRoute(store, request), handleGetThreadRoute: (request: AuthenticatedRequest) => handleGetThreadRoute(store, request), handlePostMessageRoute: (request: AuthenticatedRequest) => handlePostMessageRoute(store, request), + handleMarkReadRoute: (request: AuthenticatedRequest) => handleMarkReadRoute(store, request), }; } diff --git a/packages/api/src/harness/store.ts b/packages/api/src/harness/store.ts index d119eb21..12803f03 100644 --- a/packages/api/src/harness/store.ts +++ b/packages/api/src/harness/store.ts @@ -39,7 +39,7 @@ export type HarnessStore = { getAgentTask: (orgId: string, agentId: string, taskId: string) => Promise>; createAgentTask: (task: NewAgentTask) => Promise>; - listPersonas: (orgId: string, userId: string) => Promise, ApiError>>; + listPersonas: (orgId: string, userId: string) => Promise, ApiError>>; ensureFirstPersona: (orgId: string) => Promise>; getPersona: (orgId: string, personaId: string) => Promise>; createPersona: (orgId: string, createdById: string, persona: Persona) => Promise>; @@ -50,6 +50,7 @@ export type HarnessStore = { listShownEntries: (threadId: string) => Promise>; listOpenJobs: (threadId: string) => Promise>; appendPersonMessage: (threadId: string, text: string) => Promise>; + markThreadRead: (threadId: string) => Promise>; completeSetAlert: (job: {jobId: string; workerId: string; threadId: string; callId: string}, alert: NewAlert, answer: (alert: AlertRow) => string) => Promise>; deleteAlert: (alertId: string, owner: {threadId: string} | {orgId: string; userId: string}) => Promise>; @@ -96,6 +97,7 @@ export const harnessStore: HarnessStore = { listShownEntries: harness.listShownEntries, listOpenJobs: harness.listOpenJobs, appendPersonMessage: (threadId, text) => harness.appendPersonMessage(threadId, text, readyForInference), + markThreadRead: harness.markThreadRead, completeSetAlert: (job, alert, answer) => alerts.completeSetAlert(job, alert, answer, readyForInference), deleteAlert: alerts.deleteAlert, diff --git a/packages/api/src/server/create-api-router.ts b/packages/api/src/server/create-api-router.ts index 54e0d982..ec524abf 100644 --- a/packages/api/src/server/create-api-router.ts +++ b/packages/api/src/server/create-api-router.ts @@ -205,6 +205,7 @@ export function createApiRouter(): express.Router { org.delete('/chat/personas/:personaId', withAuthResult('Delete persona route', chat.handleDeletePersonaRoute)); org.get('/chat/personas/:personaId/thread', withAuthResult('Get chat thread route', chat.handleGetThreadRoute)); org.post('/chat/personas/:personaId/messages', withAuthResult('Post chat message route', chat.handlePostMessageRoute)); + org.post('/chat/personas/:personaId/read', withAuthResult('Mark chat read route', chat.handleMarkReadRoute)); // The Alerts page: what the agents set in the member's conversations, to be woken outside them. const alerts = alertRoutes(harnessStore); org.get('/alerts', withAuthResult('List alerts route', alerts.handleListAlertsRoute)); diff --git a/packages/contracts/src/harness.ts b/packages/contracts/src/harness.ts index be8c3918..e6d4c907 100644 --- a/packages/contracts/src/harness.ts +++ b/packages/contracts/src/harness.ts @@ -48,7 +48,14 @@ export const chatPersonaSchema = z.object({ * The last thing said in the person's conversation with it, cut to a line, for the list; null * before anything was said. */ - lastMessage: z.object({kind: speakerSchema, text: z.string(), at: z.string()}).nullable(), + lastMessage: z + .object({id: z.string(), kind: speakerSchema, text: z.string(), at: z.string()}) + .nullable(), + /** + * Whether the persona has said something the person hasn't seen: its last word in the + * conversation came after the person last read it (`POST chat/personas/:personaId/read`). + */ + unread: z.boolean(), }); export type ChatPersona = z.infer; diff --git a/packages/db/prisma/migrations/v045_add_thread_read_cursor/migration.sql b/packages/db/prisma/migrations/v045_add_thread_read_cursor/migration.sql new file mode 100644 index 00000000..3f2e0dc3 --- /dev/null +++ b/packages/db/prisma/migrations/v045_add_thread_read_cursor/migration.sql @@ -0,0 +1,9 @@ +-- How far each member has read their conversation: the `seq` of the last entry they have seen. +-- Every conversation there is counts as read up to its last entry, so nothing said before this +-- turns unread. + +-- AlterTable +ALTER TABLE "AgentThread" ADD COLUMN "readSeq" INTEGER NOT NULL DEFAULT 0; + +UPDATE "AgentThread" AS t +SET "readSeq" = COALESCE((SELECT MAX(e."seq") FROM "AgentEntry" AS e WHERE e."threadId" = t."id"), 0); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 247366ce..f7aa5147 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -662,6 +662,10 @@ model AgentThread { // and made anew when it is taken up again; a conversation has none: its agent makes no calls // but to its sub-agents. taskId String? + // How far the member has read their conversation: the `seq` of the last entry they have seen, + // moved on by the Chat page while they look at it, and by what they say, never back. What the + // persona said past it is unread. A sub-agent's thread, which nobody reads, keeps 0. + readSeq Int @default(0) createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt diff --git a/packages/db/src/harness.test.ts b/packages/db/src/harness.test.ts index 2e9b8890..a94b5876 100644 --- a/packages/db/src/harness.test.ts +++ b/packages/db/src/harness.test.ts @@ -18,7 +18,9 @@ import { listEntries, listJobEvents, listOpenJobs, + listPersonas, listSubAgents, + markThreadRead, requeueJob, type JobRow, type NewEntry, @@ -268,6 +270,47 @@ describe.skipIf(!databaseUrl)('the harness, against the database', () => { ]); }); + test('the persona’s answer is unread until the thread is marked read, and what the person says reads everything before it', async () => { + const persona = unwrap(await ensureFirstPersona(orgId)); + const unreadOf = async () => + unwrap(await listPersonas(orgId, userId)).find((candidate) => candidate.id === persona.id) + ?.unread; + + unwrap(await appendPersonMessage(thread.id, 'Anything new?', readyForInference)); + expect(await unreadOf()).toBe(false); + + const job = unwrap(await claimJob('worker-r', 60_000)); + if (!job) { + throw new Error('nothing to claim'); + } + unwrap( + await completeInference( + job.id, + 'worker-r', + thread.id, + subTurn('Nothing new.'), + {plans: {}}, + readyForInference, + ), + ); + expect(await unreadOf()).toBe(true); + + expect(unwrap(await markThreadRead(thread.id))).toBe(true); + expect(await unreadOf()).toBe(false); + expect(unwrap(await markThreadRead('no-such-thread'))).toBe(false); + + const said = unwrap(await appendPersonMessage(thread.id, 'Thanks', readyForInference)); + if (!said) { + throw new Error('the thread is gone'); + } + const read = await db.agentThread.findUniqueOrThrow({ + where: {id: thread.id}, + select: {readSeq: true}, + }); + expect(read.readSeq).toBe(said.seq); + expect(await unreadOf()).toBe(false); + }); + test('a finish with nothing written is a done event too', async () => { unwrap(await appendPersonMessage(thread.id, 'Try again', readyForInference)); const job = unwrap(await claimJob('worker-e', 60_000)); diff --git a/packages/db/src/harness.ts b/packages/db/src/harness.ts index 42458811..1140618a 100644 --- a/packages/db/src/harness.ts +++ b/packages/db/src/harness.ts @@ -283,11 +283,16 @@ export function toEntry(entry: EntryRecord): EntryRow { }; } -/** The organization's personas, oldest first, with the last thing said in the member's conversation with each. */ +/** + * The organization's personas, oldest first, with the last thing said in the member's conversation + * with each, and whether it is unread: the persona's, said past where the member has read to. + */ export async function listPersonas( orgId: string, userId: string, -): Promise, ApiError>> { +): Promise< + Result, ApiError> +> { return ( await wrapDb(() => db.persona.findMany({ @@ -297,12 +302,13 @@ export async function listPersonas( threads: { where: {userId, parentThreadId: null}, select: { + readSeq: true, // The agent's turns that only made calls say nothing, so the last with words. entries: { where: {kind: {in: ['person', 'assistant']}, text: {not: ''}}, orderBy: {seq: 'desc'}, take: 1, - select: {kind: true, text: true, createdAt: true}, + select: {id: true, seq: true, kind: true, text: true, createdAt: true}, }, }, }, @@ -312,13 +318,15 @@ export async function listPersonas( ) ).map((personas) => personas.map(({threads, ...persona}) => { - const last = threads[0]?.entries[0]; + const thread = threads[0]; + const last = thread?.entries[0]; + if (!thread || !last || (last.kind !== 'person' && last.kind !== 'assistant')) { + return {...toPersona(persona), lastMessage: null, unread: false}; + } return { ...toPersona(persona), - lastMessage: - last && (last.kind === 'person' || last.kind === 'assistant') - ? {kind: last.kind, text: last.text, createdAt: last.createdAt} - : null, + lastMessage: {id: last.id, kind: last.kind, text: last.text, createdAt: last.createdAt}, + unread: last.kind === 'assistant' && last.seq > thread.readSeq, }; }), ); @@ -475,7 +483,20 @@ export async function ensureMemberThread(data: { } /** The last thing said in a thread, by the person or the agent. */ -export type LastMessage = {kind: 'person' | 'assistant'; text: string; createdAt: Date}; +export type LastMessage = {id: string; kind: 'person' | 'assistant'; text: string; createdAt: Date}; + +/** + * Marks the thread read to its end: everything in it now, so what the persona says next is + * unread. `Ok(false)` means no such thread. + */ +export async function markThreadRead(threadId: string): Promise> { + return ( + await wrapDb( + () => + db.$executeRaw`UPDATE "AgentThread" SET "readSeq" = GREATEST("readSeq", COALESCE((SELECT MAX("seq") FROM "AgentEntry" WHERE "threadId" = ${threadId}), 0)) WHERE "id" = ${threadId}`, + ) + ).map((count) => count > 0); +} /** * The thread's agent as it acts in the thread: signed in as the member whose conversation it is, @@ -770,7 +791,10 @@ async function endTurn( await tellParent(tx, thread, stop.stopped, jobId, wakes); } -/** Lands what the person said, and wakes the agent where the log `wakes` it. `Ok(null)` means no such thread. */ +/** + * Lands what the person said, and wakes the agent where the log `wakes` it. The person has read + * everything before what they say, so the thread is read to it. `Ok(null)` means no such thread. + */ export async function appendPersonMessage( threadId: string, text: string, @@ -782,7 +806,9 @@ export async function appendPersonMessage( if (!thread) { return null; } - return appendEntry(tx, thread, {kind: 'person', text}, wakes); + const entry = await appendEntry(tx, thread, {kind: 'person', text}, wakes); + await tx.$executeRaw`UPDATE "AgentThread" SET "readSeq" = GREATEST("readSeq", ${entry.seq}) WHERE "id" = ${threadId}`; + return entry; }), ); } diff --git a/packages/desktop/README.md b/packages/desktop/README.md index d984febc..749e3b7b 100644 --- a/packages/desktop/README.md +++ b/packages/desktop/README.md @@ -16,6 +16,12 @@ everything but the app's own origin out of the window: links out of the app open is downloaded at once and announced to the page through the window's preload (`src/preload.ts`, `window.personalAgentProxyDesktop` to the web app), which shows its release notice with a restart button. Put off, the update is installed when the app next quits. +- Notifications (`src/notifications.ts`): the web app asks for one through the preload, with the + agent's name, what it said and the page of the app it is on, when an agent answers in Chat + while the person is elsewhere and they have turned notifications on; the app shows it as the + system's, and, clicked, focuses the window and hands the page back to the web app, which opens + it, or opens a new window on it where there is none. The web app sets the count of conversations + with something unread on the app's badge the same way. - Signing in (`src/auth.ts`, `packages/api/src/auth/desktop.ts`): the app keeps a PKCE verifier and opens the browser at `/login?callbackUrl=/desktop-auth?challenge=…`. Signed in there, the `/desktop-auth` page mints a one-time code bound to the challenge and hands it back through diff --git a/packages/desktop/src/channels.ts b/packages/desktop/src/channels.ts index 1c5d1bec..f480166f 100644 --- a/packages/desktop/src/channels.ts +++ b/packages/desktop/src/channels.ts @@ -1,6 +1,12 @@ -// The channels between the main process (updates.ts) and the page (preload.ts). +// The channels between the main process (updates.ts, notifications.ts) and the page (preload.ts). // Main to page: the version of the update downloaded. Page to main: asks for it. export const UPDATE_READY_CHANNEL = 'updates:ready'; // Page to main: restart into the downloaded update. export const RESTART_CHANNEL = 'updates:restart'; +// Page to main: show a notification of what an agent said, with the page of the app it is on. +export const NOTIFY_CHANNEL = 'notifications:show'; +// Main to page: the page of the app to open, from a notification clicked. +export const OPEN_PATH_CHANNEL = 'notifications:open'; +// Page to main: how many conversations have something unread, for the app's badge. +export const BADGE_CHANNEL = 'badge:set'; diff --git a/packages/desktop/src/ipc.ts b/packages/desktop/src/ipc.ts new file mode 100644 index 00000000..456c18d4 --- /dev/null +++ b/packages/desktop/src/ipc.ts @@ -0,0 +1,9 @@ +import type {IpcMainEvent, IpcMainInvokeEvent} from 'electron'; + +import {APP_ORIGIN} from './env'; + +/** Whether a page of the app's asks: a bank's sign-in popup has the window's preload too. */ +export function fromApp(event: IpcMainEvent | IpcMainInvokeEvent): boolean { + const url = event.senderFrame?.url; + return url !== undefined && URL.canParse(url) && new URL(url).origin === APP_ORIGIN; +} diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index 41582e0b..cb79c5d4 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -3,14 +3,17 @@ import path from 'node:path'; import {app, dialog, type BrowserWindow} from 'electron'; import {finishSignIn} from './auth'; +import {OPEN_PATH_CHANNEL} from './channels'; import {DEEP_LINK_SCHEME, findDeepLink, parseDeepLink, type DeepLink} from './deep-link'; import {DESKTOP_VERSION} from './env'; +import {initNotifications} from './notifications'; import {initUpdates} from './updates'; import {createMainWindow, loadApp, showSignIn} from './window'; // The desktop app: a window around the web app, hosted, with nothing of it bundled. The main -// process keeps the window, signs in through the browser, and takes the app's links: the browser -// coming back from the sign-in, and from what the app hands it, as a service's sign-in is. +// process keeps the window, signs in through the browser, takes the app's links: the browser +// coming back from the sign-in, and from what the app hands it, as a service's sign-in is; and +// shows the notifications the page asks for. // So the web app can tell it is in the desktop app, and which version. app.userAgentFallback = `${app.userAgentFallback} PersonalAgentProxyDesktop/${DESKTOP_VERSION}`; @@ -44,6 +47,17 @@ function focusWindow(): BrowserWindow { return win; } +// Opens a page of the app, as a notification clicked asks: in the window as it is, which moves +// there itself, or in a new one where there is none, as on macOS after the window was closed. +function openPath(path: string): void { + const open = currentWindow(); + if (!open) { + loadApp(focusWindow(), path); + return; + } + focusWindow().webContents.send(OPEN_PATH_CHANNEL, path); +} + async function onDeepLink(link: DeepLink): Promise { await app.whenReady(); const win = focusWindow(); @@ -117,6 +131,7 @@ function start(): void { } loadApp(window()); initUpdates(currentWindow); + initNotifications(openPath); const link = findDeepLink(process.argv); if (link) { void onDeepLink(link); diff --git a/packages/desktop/src/notifications.ts b/packages/desktop/src/notifications.ts new file mode 100644 index 00000000..43509f38 --- /dev/null +++ b/packages/desktop/src/notifications.ts @@ -0,0 +1,31 @@ +import {app, ipcMain, Notification} from 'electron'; +import {z} from 'zod'; + +import {BADGE_CHANNEL, NOTIFY_CHANNEL} from './channels'; +import {fromApp} from './ipc'; + +// Notifications, as the page asks for them: what an agent said while the person was elsewhere, +// shown by the system, and the count of conversations with something unread on the app's badge. +// A notification clicked opens its conversation, the page it names, in the window. + +const noticeSchema = z.object({title: z.string(), body: z.string(), path: z.string()}); + +export function initNotifications(openPath: (path: string) => void): void { + ipcMain.on(NOTIFY_CHANNEL, (event, raw) => { + const notice = noticeSchema.safeParse(raw); + if (!fromApp(event) || !notice.success || !Notification.isSupported()) { + return; + } + const shown = new Notification({title: notice.data.title, body: notice.data.body}); + shown.on('click', () => openPath(notice.data.path)); + shown.show(); + }); + + ipcMain.on(BADGE_CHANNEL, (event, raw) => { + const count = z.number().int().min(0).safeParse(raw); + if (!fromApp(event) || !count.success) { + return; + } + app.setBadgeCount(count.data); + }); +} diff --git a/packages/desktop/src/preload.ts b/packages/desktop/src/preload.ts index 6ddcbd26..aeee8e6e 100644 --- a/packages/desktop/src/preload.ts +++ b/packages/desktop/src/preload.ts @@ -1,7 +1,13 @@ import {contextBridge, ipcRenderer, type IpcRendererEvent, webFrame} from 'electron'; import {PRODUCTION_APP_URL} from './app-url'; -import {RESTART_CHANNEL, UPDATE_READY_CHANNEL} from './channels'; +import { + BADGE_CHANNEL, + NOTIFY_CHANNEL, + OPEN_PATH_CHANNEL, + RESTART_CHANNEL, + UPDATE_READY_CHANNEL, +} from './channels'; const APP_ORIGIN = new URL( process.env.NODE_ENV === 'production' @@ -41,7 +47,9 @@ if (process.platform === 'darwin' && window.location.origin === APP_ORIGIN) { // What the page may ask of the app, as `window.personalAgentProxyDesktop`, typed on the web app's // side in packages/web/src/lib/desktop.ts: whether an update is downloaded, word when one is, and -// the restart that installs it. The main process answers the app's own pages only (updates.ts). +// the restart that installs it; a notification shown, word of the page one opens when clicked, +// and the count on the app's badge. The main process answers the app's own pages only +// (updates.ts, notifications.ts). contextBridge.exposeInMainWorld('personalAgentProxyDesktop', { titleBarHidden: process.platform === 'darwin', @@ -52,4 +60,12 @@ contextBridge.exposeInMainWorld('personalAgentProxyDesktop', { return () => ipcRenderer.removeListener(UPDATE_READY_CHANNEL, handler); }, restart: (): void => ipcRenderer.send(RESTART_CHANNEL), + notify: (notice: {title: string; body: string; path: string}): void => + ipcRenderer.send(NOTIFY_CHANNEL, notice), + onOpenPath: (listener: (path: string) => void): (() => void) => { + const handler = (_event: IpcRendererEvent, path: string) => listener(path); + ipcRenderer.on(OPEN_PATH_CHANNEL, handler); + return () => ipcRenderer.removeListener(OPEN_PATH_CHANNEL, handler); + }, + setBadge: (count: number): void => ipcRenderer.send(BADGE_CHANNEL, count), }); diff --git a/packages/desktop/src/updates.ts b/packages/desktop/src/updates.ts index 4e7ef29e..424d3057 100644 --- a/packages/desktop/src/updates.ts +++ b/packages/desktop/src/updates.ts @@ -1,14 +1,8 @@ -import { - app, - ipcMain, - type BrowserWindow, - type IpcMainEvent, - type IpcMainInvokeEvent, -} from 'electron'; +import {app, ipcMain, type BrowserWindow} from 'electron'; import {autoUpdater} from 'electron-updater'; import {RESTART_CHANNEL, UPDATE_READY_CHANNEL} from './channels'; -import {APP_ORIGIN} from './env'; +import {fromApp} from './ipc'; // Updates, from the feed electron-builder.yml names: checked on start and every few hours. A new // version is downloaded as soon as it is found and announced to the page, which shows its notice @@ -19,12 +13,6 @@ const CHECK_INTERVAL_MS = 4 * 60 * 60 * 1000; let readyVersion: string | null = null; -// Only a page of the app's may ask: a bank's sign-in popup has the window's preload too. -function fromApp(event: IpcMainEvent | IpcMainInvokeEvent): boolean { - const url = event.senderFrame?.url; - return url !== undefined && URL.canParse(url) && new URL(url).origin === APP_ORIGIN; -} - export function initUpdates(currentWindow: () => BrowserWindow | null): void { // A page loaded after the download, as one opened again from the Dock, asks. ipcMain.handle(UPDATE_READY_CHANNEL, (event) => (fromApp(event) ? readyVersion : null)); diff --git a/packages/docs/content/agents/chat.mdx b/packages/docs/content/agents/chat.mdx index e18b9357..8f9d392d 100644 --- a/packages/docs/content/agents/chat.mdx +++ b/packages/docs/content/agents/chat.mdx @@ -58,6 +58,14 @@ A conversation can go on for as long as you like. Once it has grown long, the ag summary of the earlier part in place of it, and keeps the last turns as they were; the page shows the whole conversation as before. +An answer that comes while you are elsewhere, on another page or away from the app, is unread +until you open the conversation: the agent's row in the list is marked, **Chat** in the sidebar +counts the conversations with something new, and so does the window's title. To be told as it +comes, turn on notifications on the **Account** page: a notification of the system's, on that +device, with the agent's name and what it said, which opens the conversation when clicked. In the +browser this asks the browser's leave, which it asks you for once; in the desktop app the count is +on the app's icon too. + **Activity** under the input opens the activity log, where every sub-agent's task is listed with the agent's head and name. diff --git a/packages/web/src/client/harness-client.ts b/packages/web/src/client/harness-client.ts index 8d5859e7..61c98a67 100644 --- a/packages/web/src/client/harness-client.ts +++ b/packages/web/src/client/harness-client.ts @@ -10,7 +10,8 @@ import type {z} from 'zod'; import {apiRequest, apiSend, orgApi} from '@/client/request'; // The Chat page's api: the organization's personas, each named and given a head, which every -// member sees, the person's own conversation with each, its turns, and what they say in it. +// member sees, the person's own conversation with each, its turns, what they say in it, and how +// far they have read it. export function listPersonas(org: string) { return apiRequest('GET', `${orgApi(org)}/chat/personas`, personaListSchema); @@ -45,3 +46,7 @@ export function postMessage(org: string, personaId: string, text: string) { {text} satisfies z.input, ); } + +export function markRead(org: string, personaId: string) { + return apiSend('POST', `${orgApi(org)}/chat/personas/${personaId}/read`); +} diff --git a/packages/web/src/components/human-side.tsx b/packages/web/src/components/human-side.tsx index f809b05e..aa040e1d 100644 --- a/packages/web/src/components/human-side.tsx +++ b/packages/web/src/components/human-side.tsx @@ -1,15 +1,19 @@ import {Outlet} from 'react-router'; import {SidebarProvider} from '@proxy/ui/components/sidebar'; +import {UnreadProvider} from '@/components/unread-provider'; import {useOrgSlug} from '@/hooks/use-org'; // The human side, behind its sign-in (the route's loader). The sidebar is always a rail on // desktop. Keyed by the organization, so a page switched to another's starts over, rather than -// keeping what was typed, picked or open in the one before. +// keeping what was typed, picked or open in the one before; what is unread in the organization's +// conversations is kept around every page of it. export function HumanSide() { const org = useOrgSlug(); return ( - + + + ); } diff --git a/packages/web/src/components/nav.test.tsx b/packages/web/src/components/nav.test.tsx index a33c0616..678888be 100644 --- a/packages/web/src/components/nav.test.tsx +++ b/packages/web/src/components/nav.test.tsx @@ -7,6 +7,7 @@ import {SidebarProvider} from '@proxy/ui/components/sidebar'; import {ThemeProvider} from '@proxy/ui/components/theme-provider'; import {TooltipProvider} from '@proxy/ui/components/tooltip'; import {AppSidebar} from '@/components/nav'; +import {type Unread, UnreadContext} from '@/hooks/use-unread'; import type {Human} from '@/lib/orgs'; import {testHuman, testOrg} from '@/test-org'; @@ -14,7 +15,18 @@ afterEach(() => { cleanup(); }); -function renderSidebar(human: Human, entry = '/acme') { +// What is unread, as the provider around the human side would have it. +function unreadOf(ids: string[]): Unread { + return { + loaded: true, + unreadIds: new Set(ids), + count: ids.length, + setOpen: () => undefined, + markSeen: () => undefined, + }; +} + +function renderSidebar(human: Human, entry = '/acme', unread = unreadOf([])) { const router = createMemoryRouter( [ { @@ -31,7 +43,9 @@ function renderSidebar(human: Human, entry = '/acme') { - + + + , @@ -77,6 +91,19 @@ describe('AppSidebar', () => { ]); }); + test('counts the conversations with something unread on Chat', async () => { + renderSidebar( + testHuman({...testOrg, experiments: ['harness']}), + '/acme', + unreadOf(['general', 'finance']), + ); + await screen.findByRole('link', {name: 'Chat'}); + + const badge = screen.getByRole('status', {name: '2 unread'}); + expect(badge.textContent).toBe('2'); + expect(badge.closest('li')?.querySelector('a')?.textContent).toBe('Chat'); + }); + test("lights the switcher on the organization's members and settings", async () => { renderSidebar(testHuman(), '/acme/members'); diff --git a/packages/web/src/components/nav.tsx b/packages/web/src/components/nav.tsx index db5d533c..e4ec0875 100644 --- a/packages/web/src/components/nav.tsx +++ b/packages/web/src/components/nav.tsx @@ -28,10 +28,27 @@ import { useSidebar, } from '@proxy/ui/components/sidebar'; import {useHuman} from '@/hooks/use-human'; +import {useUnread} from '@/hooks/use-unread'; // `experiment` is the experiment a destination is behind, shown only with it on. type Destination = {to: string; label: string; icon: LucideIcon; experiment?: Experiment}; +const CHAT = '/chat'; + +// How many conversations have something unread, on Chat: over the icon's corner on the rail, at +// the row's end where the labels show. +function UnreadBadge({count}: {count: number}) { + return ( + + {count} + + ); +} + // The organization's, under the switcher, each under its slug, in groups set apart from each // other. Its members and settings are in the switcher. const GROUPS: Destination[][] = [ @@ -65,6 +82,7 @@ export function AppSidebar() { const [confirmingSignOut, setConfirmingSignOut] = useState(false); const human = useHuman(); const experiments = useExperiments(); + const unread = useUnread(); const {setOpenMobile} = useSidebar(); const home = `/${human?.org.slug ?? ''}`; const groups = GROUPS.map((group) => @@ -99,6 +117,9 @@ export function AppSidebar() { {destination.label} + {destination.to === CHAT && unread.count > 0 && ( + + )} ))} diff --git a/packages/web/src/components/notification-setting.tsx b/packages/web/src/components/notification-setting.tsx new file mode 100644 index 00000000..db089587 --- /dev/null +++ b/packages/web/src/components/notification-setting.tsx @@ -0,0 +1,40 @@ +import {useState} from 'react'; +import {Button} from '@proxy/ui/components/button'; +import { + notificationsBlocked, + notificationsOn, + turnNotificationsOff, + turnNotificationsOn, +} from '@/lib/notifications'; + +// Notifications of what the agents say while the person is elsewhere, on this device: on or off, +// with the button that turns them the other way; where the browser has refused the page its +// leave, the way to change that is the browser's own. +export function NotificationSetting() { + const [on, setOn] = useState(notificationsOn); + const [blocked, setBlocked] = useState(notificationsBlocked); + + const turnOn = async () => { + const allowed = await turnNotificationsOn(); + setOn(allowed); + setBlocked(notificationsBlocked()); + }; + + const turnOff = () => { + turnNotificationsOff(); + setOn(false); + }; + + if (blocked) { + return Blocked in the browser’s settings; + } + + return ( + + {on ? 'On' : 'Off'} + + + ); +} diff --git a/packages/web/src/components/unread-provider.test.tsx b/packages/web/src/components/unread-provider.test.tsx new file mode 100644 index 00000000..defea4a5 --- /dev/null +++ b/packages/web/src/components/unread-provider.test.tsx @@ -0,0 +1,123 @@ +/// + +import type {ChatPersona} from '@proxy/contracts/harness'; +import type {Experiment} from '@proxy/utils'; +import {afterEach, beforeEach, describe, expect, mock, test} from 'bun:test'; +import {act, cleanup, render, screen, waitFor} from '@testing-library/react'; +import {createMemoryRouter, RouterProvider} from 'react-router'; +import {HumanSide} from '@/components/human-side'; +import {useUnread} from '@/hooks/use-unread'; +import {testHuman, testOrg} 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, unread: boolean): ChatPersona { + return { + id, + name: id, + mayChange: false, + look, + color: 'blue', + chatModel: null, + unread, + lastMessage: {id: `${id}-last`, kind: 'assistant', text: 'Done.', at: '2026-10-21T10:00:00Z'}, + }; +} + +// A page that shows what the provider has, and opens a conversation on request. +function Consumer() { + const unread = useUnread(); + return ( +
+ {unread.count} + {[...unread.unreadIds].join(',')} + + +
+ ); +} + +function renderProvider(experiments: Experiment[] = ['harness']) { + const router = createMemoryRouter( + [ + { + id: 'human', + loader: () => testHuman({...testOrg, experiments}), + element: , + children: [{path: '/:org', element: }], + }, + ], + {initialEntries: ['/acme']}, + ); + return render(); +} + +const setBadge = mock((_count: number) => undefined); + +beforeEach(() => { + fetchMock.mockReset(); + fetchMock.mockImplementation(() => + Promise.resolve( + Response.json({personas: [persona('general', true), persona('finance', true)]}), + ), + ); + globalThis.fetch = ((input: string, init?: RequestInit) => + fetchMock(input, init)) as typeof fetch; + setBadge.mockClear(); + window.personalAgentProxyDesktop = { + updateReady: () => Promise.resolve(null), + onUpdateReady: () => () => undefined, + restart: () => undefined, + notify: () => undefined, + onOpenPath: () => () => undefined, + setBadge, + }; + document.title = 'Personal Agent Proxy'; +}); + +afterEach(() => { + cleanup(); + globalThis.fetch = realFetch; + delete window.personalAgentProxyDesktop; +}); + +describe('UnreadProvider', () => { + test('asks for the conversations, counts those with something unread, in the title and on the badge, less the one open, and one seen', async () => { + renderProvider(); + + await waitFor(() => { + expect(screen.getByTestId('count').textContent).toBe('2'); + }); + expect(fetchMock).toHaveBeenCalledWith('/api/orgs/acme/chat/personas', expect.anything()); + expect(screen.getByTestId('ids').textContent).toBe('general,finance'); + expect(document.title).toBe('(2) Personal Agent Proxy'); + expect(setBadge).toHaveBeenLastCalledWith(2); + + act(() => { + screen.getByRole('button', {name: 'Open General'}).click(); + }); + expect(screen.getByTestId('count').textContent).toBe('1'); + expect(screen.getByTestId('ids').textContent).toBe('finance'); + + act(() => { + screen.getByRole('button', {name: 'Seen Finance'}).click(); + }); + expect(screen.getByTestId('count').textContent).toBe('0'); + expect(document.title).toBe('Personal Agent Proxy'); + expect(setBadge).toHaveBeenLastCalledWith(0); + }); + + test('asks nothing without the harness experiment', async () => { + renderProvider([]); + + await screen.findByTestId('count'); + expect(screen.getByTestId('count').textContent).toBe('0'); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/web/src/components/unread-provider.tsx b/packages/web/src/components/unread-provider.tsx new file mode 100644 index 00000000..4bf83783 --- /dev/null +++ b/packages/web/src/components/unread-provider.tsx @@ -0,0 +1,146 @@ +import type {ChatPersona} from '@proxy/contracts/harness'; +import {type ReactNode, useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {useNavigate} from 'react-router'; +import {listPersonas} from '@/client/harness-client'; +import {useExperiments} from '@/hooks/use-experiment'; +import {useOrgSlug} from '@/hooks/use-org'; +import {type Unread, UnreadContext} from '@/hooks/use-unread'; +import {desktopBridge} from '@/lib/desktop'; +import {notify} from '@/lib/notifications'; + +// What the organization's agents said that the person hasn't seen, asked for every so often from +// every page, and when the page comes back into view: the count on the sidebar's Chat, in the +// window's title and on the desktop app's badge, the mark on each conversation in Chat's list, and +// a notification for what came while the person was elsewhere. The conversation the Chat page +// shows while it is in view is never unread or notified of: the page marks it read as it reads it. + +const POLL_MS = 20 * 1000; +const VISIBLE_POLL_GAP_MS = 5 * 1000; + +// Whether the persona said something the person hasn't seen, that a notice would be of. +function unreadFrom(persona: ChatPersona): ChatPersona['lastMessage'] { + if (!persona.unread || persona.lastMessage?.kind !== 'assistant') { + return null; + } + return persona.lastMessage; +} + +export function UnreadProvider({children}: {children: ReactNode}) { + const org = useOrgSlug(); + const navigate = useNavigate(); + const experiments = useExperiments(); + const enabled = org !== '' && experiments.includes('harness'); + const [personas, setPersonas] = useState(null); + const [open, setOpenState] = useState(null); + // The open conversation as the poll, outside render, reads it. + const openRef = useRef(null); + const setOpen = useCallback((personaId: string | null) => { + openRef.current = personaId; + setOpenState(personaId); + }, []); + // What each persona was last told of, by the message: the first answer only sets this, so a + // page opened on old news doesn't repeat it, and everything since is told of once. + const told = useRef | null>(null); + + const poll = useCallback(async () => { + const result = await listPersonas(org); + if (result.isErr()) { + return; + } + const listed = result.value.personas; + setPersonas(listed); + const known = told.current; + told.current = new Map( + listed.flatMap((persona) => { + const last = unreadFrom(persona); + return last ? [[persona.id, last.id] as const] : []; + }), + ); + if (!known) { + return; + } + for (const persona of listed) { + const last = unreadFrom(persona); + if (!last || persona.id === openRef.current || known.get(persona.id) === last.id) { + continue; + } + notify( + {title: persona.name, body: last.text, path: `/${org}/chat/${persona.id}`}, + persona.id, + (path) => void navigate(path), + ); + } + }, [org, navigate]); + + useEffect(() => { + if (!enabled) { + return; + } + let lastPoll = Date.now(); + const ask = () => { + lastPoll = Date.now(); + void poll(); + }; + const onVisible = () => { + if (document.visibilityState !== 'visible' || Date.now() - lastPoll < VISIBLE_POLL_GAP_MS) { + return; + } + ask(); + }; + ask(); + const timer = setInterval(ask, POLL_MS); + document.addEventListener('visibilitychange', onVisible); + window.addEventListener('focus', onVisible); + return () => { + clearInterval(timer); + document.removeEventListener('visibilitychange', onVisible); + window.removeEventListener('focus', onVisible); + }; + }, [enabled, poll]); + + // A notification clicked in the desktop app opens its conversation. + useEffect(() => desktopBridge()?.onOpenPath?.((path) => void navigate(path)), [navigate]); + + const markSeen = useCallback((personaId: string) => { + setPersonas((current) => { + if (!current?.some((persona) => persona.id === personaId && persona.unread)) { + return current; + } + return current.map((persona) => + persona.id === personaId ? {...persona, unread: false} : persona, + ); + }); + }, []); + + const unreadIds = useMemo( + () => + new Set( + (personas ?? []) + .filter((persona) => persona.unread && persona.id !== open) + .map((persona) => persona.id), + ), + [personas, open], + ); + const count = unreadIds.size; + + // The count in the window's title and on the desktop app's badge, while there is one. + useEffect(() => { + const title = document.title; + if (count === 0) { + return; + } + document.title = `(${count}) ${title}`; + return () => { + document.title = title; + }; + }, [count]); + useEffect(() => { + desktopBridge()?.setBadge?.(count); + }, [count]); + + const value = useMemo( + () => ({loaded: personas !== null, unreadIds, count, setOpen, markSeen}), + [personas, unreadIds, count, setOpen, markSeen], + ); + return {children}; +} diff --git a/packages/web/src/hooks/use-unread.ts b/packages/web/src/hooks/use-unread.ts new file mode 100644 index 00000000..c97c386e --- /dev/null +++ b/packages/web/src/hooks/use-unread.ts @@ -0,0 +1,34 @@ +import {createContext, useContext} from 'react'; + +/** + * What the agents said that the person hasn't seen, across the organization's conversations, as + * the provider around the human side keeps it (components/unread-provider.tsx). + */ +export type Unread = { + // Whether the conversations have been asked about since the page opened. + loaded: boolean; + // The personas with something unread, the one open in view left out. + unreadIds: ReadonlySet; + // How many conversations have something unread. + count: number; + // Which conversation the Chat page shows while it is in view, which counts as read; null + // elsewhere, and while the page is out of view. + setOpen: (personaId: string | null) => void; + // The person has seen the conversation: unread no longer, at once, ahead of the next poll. + markSeen: (personaId: string) => void; +}; + +const NOTHING: Unread = { + loaded: false, + unreadIds: new Set(), + count: 0, + setOpen: () => undefined, + markSeen: () => undefined, +}; + +export const UnreadContext = createContext(NOTHING); + +/** What is unread across the conversations; nothing outside the provider. */ +export function useUnread(): Unread { + return useContext(UnreadContext); +} diff --git a/packages/web/src/lib/desktop.ts b/packages/web/src/lib/desktop.ts index d29eb5f3..3a875eaa 100644 --- a/packages/web/src/lib/desktop.ts +++ b/packages/web/src/lib/desktop.ts @@ -18,7 +18,8 @@ export function isDesktopApp(): boolean { /** * What the desktop app's window gives its pages (`packages/desktop/src/preload.ts`): the version * of an update it has downloaded, if any, word when it downloads one, and the restart that installs - * it. + * it; and notifications, shown by the app, which hands back the page one opens when clicked, and + * the count on its badge. */ export type DesktopBridge = { // Optional while installed desktop apps from before the hidden title bar still load this page. @@ -26,6 +27,10 @@ export type DesktopBridge = { updateReady(): Promise; onUpdateReady(listener: (version: string) => void): () => void; restart(): void; + // Optional while installed desktop apps from before notifications still load this page. + notify?(notice: {title: string; body: string; path: string}): void; + onOpenPath?(listener: (path: string) => void): () => void; + setBadge?(count: number): void; }; declare global { diff --git a/packages/web/src/lib/notifications.test.ts b/packages/web/src/lib/notifications.test.ts new file mode 100644 index 00000000..ef525629 --- /dev/null +++ b/packages/web/src/lib/notifications.test.ts @@ -0,0 +1,99 @@ +/// + +import {afterEach, beforeEach, describe, expect, mock, test} from 'bun:test'; +import { + notificationSupport, + notificationsOn, + notify, + turnNotificationsOff, + turnNotificationsOn, +} from '@/lib/notifications'; + +// A browser's Notification, as far as the page uses it: the leave it has, the one it asks for, +// and what was shown. +const shown: Array<{title: string; options?: NotificationOptions}> = []; +let permission: NotificationPermission = 'default'; +class FakeNotification { + static get permission() { + return permission; + } + static requestPermission = mock(() => Promise.resolve(permission)); + onclick: (() => void) | null = null; + close = mock(() => undefined); + constructor(title: string, options?: NotificationOptions) { + shown.push({title, options}); + } +} + +const open = mock((_path: string) => undefined); +const notice = {title: 'Finance', body: 'Three invoices are due.', path: '/acme/chat/finance'}; + +beforeEach(() => { + shown.length = 0; + permission = 'default'; + open.mockClear(); + FakeNotification.requestPermission.mockClear(); + turnNotificationsOff(); + Object.assign(globalThis, {Notification: FakeNotification}); +}); + +afterEach(() => { + delete window.personalAgentProxyDesktop; + Reflect.deleteProperty(globalThis, 'Notification'); +}); + +describe('notifications', () => { + test('are off until turned on, which asks the browser’s leave and keeps them off where it is refused', async () => { + expect(notificationSupport()).toBe('browser'); + expect(notificationsOn()).toBe(false); + + permission = 'denied'; + expect(await turnNotificationsOn()).toBe(false); + expect(notificationsOn()).toBe(false); + + permission = 'granted'; + expect(await turnNotificationsOn()).toBe(true); + expect(notificationsOn()).toBe(true); + + turnNotificationsOff(); + expect(notificationsOn()).toBe(false); + }); + + test('show nothing while off, and through the browser once on, opening the page when clicked', async () => { + permission = 'granted'; + notify(notice, 'finance', open); + expect(shown).toEqual([]); + + await turnNotificationsOn(); + notify(notice, 'finance', open); + expect(shown).toEqual([ + {title: 'Finance', options: {body: 'Three invoices are due.', tag: 'finance'}}, + ]); + expect(open).not.toHaveBeenCalled(); + }); + + test('go through the desktop app where the page is in it, which needs no leave', async () => { + const bridge = { + updateReady: () => Promise.resolve(null), + onUpdateReady: () => () => undefined, + restart: () => undefined, + notify: mock((_notice: typeof notice) => undefined), + onOpenPath: () => () => undefined, + setBadge: () => undefined, + }; + window.personalAgentProxyDesktop = bridge; + expect(notificationSupport()).toBe('desktop'); + + expect(await turnNotificationsOn()).toBe(true); + expect(FakeNotification.requestPermission).not.toHaveBeenCalled(); + notify(notice, 'finance', open); + expect(bridge.notify).toHaveBeenCalledWith(notice); + expect(shown).toEqual([]); + }); + + test('cannot be turned on where the device has no way to show them', async () => { + Reflect.deleteProperty(globalThis, 'Notification'); + expect(notificationSupport()).toBe('none'); + expect(await turnNotificationsOn()).toBe(false); + }); +}); diff --git a/packages/web/src/lib/notifications.ts b/packages/web/src/lib/notifications.ts new file mode 100644 index 00000000..382b75bf --- /dev/null +++ b/packages/web/src/lib/notifications.ts @@ -0,0 +1,78 @@ +import {desktopBridge} from '@/lib/desktop'; + +// Word of a message from an agent that came while the person was elsewhere: a notification of +// the system's, through the desktop app where the page is in it, and through the browser's +// Notification API otherwise. Off until the person turns it on, on each device (Account page). + +const SETTING_KEY = 'proxy-notifications'; + +/** A message as it is told of: whose, what it said, and the page of the app it is on. */ +export type Notice = {title: string; body: string; path: string}; + +/** How this device can show notifications: through the desktop app, the browser, or not at all. */ +export type NotificationSupport = 'desktop' | 'browser' | 'none'; + +export function notificationSupport(): NotificationSupport { + if (desktopBridge()?.notify) { + return 'desktop'; + } + if (typeof Notification !== 'undefined') { + return 'browser'; + } + return 'none'; +} + +/** Whether the person turned notifications on, on this device. */ +export function notificationsOn(): boolean { + return localStorage.getItem(SETTING_KEY) === 'on'; +} + +/** Whether the browser lets the page notify; a desktop app needs no leave, and takes the system's. */ +export function notificationsBlocked(): boolean { + return notificationSupport() === 'browser' && Notification.permission === 'denied'; +} + +/** + * Turns notifications on for this device, asking the browser's leave where it is needed; false + * where the browser refused, or has before, so they stay off. + */ +export async function turnNotificationsOn(): Promise { + const support = notificationSupport(); + if (support === 'none') { + return false; + } + if (support === 'browser' && (await Notification.requestPermission()) !== 'granted') { + return false; + } + localStorage.setItem(SETTING_KEY, 'on'); + return true; +} + +export function turnNotificationsOff(): void { + localStorage.removeItem(SETTING_KEY); +} + +/** + * Shows the notice, where notifications are on; `open` is called with its path when it is clicked + * in the browser (the desktop app hands that back through its bridge, `onOpenPath`). One per + * conversation at a time: a newer notice of the same `tag` replaces the one showing. + */ +export function notify(notice: Notice, tag: string, open: (path: string) => void): void { + if (!notificationsOn()) { + return; + } + const bridge = desktopBridge(); + if (bridge?.notify) { + bridge.notify(notice); + return; + } + if (typeof Notification === 'undefined' || Notification.permission !== 'granted') { + return; + } + const shown = new Notification(notice.title, {body: notice.body, tag}); + shown.onclick = () => { + window.focus(); + open(notice.path); + shown.close(); + }; +} diff --git a/packages/web/src/pages/account-page.tsx b/packages/web/src/pages/account-page.tsx index ba085453..35b31e69 100644 --- a/packages/web/src/pages/account-page.tsx +++ b/packages/web/src/pages/account-page.tsx @@ -4,6 +4,7 @@ import {Link} from 'react-router'; import {deleteAccount} from '@/client/me-client'; import {AppShell, PageTitle} from '@/components/app-shell'; import {DangerCard} from '@/components/danger-card'; +import {NotificationSetting} from '@/components/notification-setting'; import {OrgTile} from '@/components/org-switcher'; import {RowList} from '@/components/row-list'; import {Section} from '@/components/section'; @@ -12,11 +13,13 @@ import {ThemePicker} from '@/components/theme-picker'; import {TypeToConfirmDialog} from '@/components/type-to-confirm-dialog'; import {useHuman} from '@/hooks/use-human'; import {describeApiError} from '@/lib/loader-utils'; +import {notificationSupport} from '@/lib/notifications'; import {hasOtherPeople} from '@/lib/orgs'; -// The person's account, in no organization: who is signed in, how the app looks to them, the -// organizations they are in, each to its own settings, and deleting the account, which deletes -// every one they own. +// The person's account, in no organization: who is signed in, how the app looks to them, whether +// this device tells them what the agents say while they are elsewhere (where any of their +// organizations has Chat, and the device can), the organizations they are in, each to its own +// settings, and deleting the account, which deletes every one they own. export function AccountPage() { const human = useHuman(); const [deleting, setDeleting] = useState(false); @@ -26,6 +29,7 @@ export function AccountPage() { // The organizations deleted with the account that others are in or invited to. const shared = human.orgs.filter((org) => org.role === 'owner' && hasOtherPeople(org)); + const chats = human.orgs.some((org) => org.experiments.includes('harness')); return ( Account}> @@ -43,6 +47,18 @@ export function AccountPage() { + {chats && notificationSupport() !== 'none' && ( +
+ + + + + +

+ A notification on this device when an agent answers in Chat while you are elsewhere. +

+
+ )}
{human.orgs.map((org) => ( diff --git a/packages/web/src/pages/chat-page.test.tsx b/packages/web/src/pages/chat-page.test.tsx index 173e2b66..30aea481 100644 --- a/packages/web/src/pages/chat-page.test.tsx +++ b/packages/web/src/pages/chat-page.test.tsx @@ -4,7 +4,7 @@ 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 {cleanup, fireEvent, render, screen, waitFor, within} from '@testing-library/react'; import {createMemoryRouter, RouterProvider} from 'react-router'; import {HumanSide} from '@/components/human-side'; import {ChatPage} from '@/pages/chat-page'; @@ -15,7 +15,16 @@ 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', chatModel: null, lastMessage: null}; + return { + id, + name, + mayChange, + look, + color: 'blue', + chatModel: null, + lastMessage: null, + unread: false, + }; } const general = persona('general', 'General', false); @@ -34,7 +43,10 @@ afterEach(() => { globalThis.fetch = realFetch; }); -function renderChatPage() { +function renderChatPage({ + personas = [general, finance], + entries = [], +}: {personas?: ChatPersona[]; entries?: ThreadLog['entries']} = {}) { const router = createMemoryRouter( [ { @@ -45,8 +57,12 @@ function renderChatPage() { { path: '/:org/chat/:id', loader: ({params}) => ({ - log: {...log, persona: params.id === finance.id ? finance : general}, - personas: [general, finance], + log: { + ...log, + entries, + persona: personas.find((candidate) => candidate.id === params.id) ?? general, + }, + personas, }), element: , }, @@ -200,4 +216,48 @@ describe('ChatPage', () => { expect(screen.queryByRole('menuitem', {name: 'Rename'})).toBeNull(); expect(screen.queryByRole('menuitem', {name: 'Delete'})).toBeNull(); }); + + test('marks the agents with something unread in the list, never the open one', async () => { + renderChatPage({ + personas: [ + {...general, unread: true}, + {...finance, unread: true}, + ], + }); + + const financeRow = await screen.findByRole('link', {name: /Finance/}); + expect(within(financeRow).getByRole('status', {name: 'Unread'})).toBeDefined(); + const generalRow = screen.getByRole('link', {name: /General/}); + expect(within(generalRow).queryByRole('status', {name: 'Unread'})).toBeNull(); + }); + + test('marks the conversation read when an answer shows while the page is in view', async () => { + renderChatPage({ + entries: [ + {id: 'e1', kind: 'person', text: 'Hi', isError: false, at: '2026-10-21T10:00:00Z'}, + {id: 'e2', kind: 'assistant', text: 'Hello', isError: false, at: '2026-10-21T10:00:01Z'}, + ], + }); + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalledWith( + '/api/orgs/acme/chat/personas/general/read', + expect.objectContaining({method: 'POST'}), + ); + }); + expect( + fetchMock.mock.calls.filter(([url]) => url === '/api/orgs/acme/chat/personas/general/read'), + ).toHaveLength(1); + }); + + test('marks nothing read while the last word is the person’s', async () => { + renderChatPage({ + entries: [{id: 'e1', kind: 'person', text: 'Hi', isError: false, at: '2026-10-21T10:00:00Z'}], + }); + + await screen.findByRole('link', {name: /Finance/}); + expect( + fetchMock.mock.calls.filter(([url]) => url === '/api/orgs/acme/chat/personas/general/read'), + ).toHaveLength(0); + }); }); diff --git a/packages/web/src/pages/chat-page.tsx b/packages/web/src/pages/chat-page.tsx index 01fbf066..bd99b32f 100644 --- a/packages/web/src/pages/chat-page.tsx +++ b/packages/web/src/pages/chat-page.tsx @@ -13,6 +13,7 @@ import { createPersona, deletePersona, getThread, + markRead, postMessage, updatePersona, } from '@/client/harness-client'; @@ -42,6 +43,7 @@ import {preview} from '@proxy/utils/harness'; import {cn} from '@proxy/ui/lib/utils'; import {useHuman} from '@/hooks/use-human'; import {useOrgSlug} from '@/hooks/use-org'; +import {useUnread} from '@/hooks/use-unread'; import {formatAgo, formatDateTime} from '@/lib/format'; import type {Pose} from '@/lib/head'; import {describeFetchError} from '@/lib/loader-utils'; @@ -121,12 +123,13 @@ function subtitle(last: ChatPersona['lastMessage']): string { // to a line as the list has it. function lastOf(log: ThreadLog): ChatPersona['lastMessage'] { const last = log.entries.at(-1); - return last ? {kind: last.kind, text: preview(last.text), at: last.at} : null; + return last ? {id: last.id, kind: last.kind, text: preview(last.text), at: last.at} : null; } // The organization's personas in the second sidebar, each with its head, its name and the last -// thing said between it and the person, the open one lit, each head watching the pointer while it -// is over that agent's row, and a row at the bottom to make a new one. +// thing said between it and the person, marked where the person hasn't seen it, the open one lit, +// each head watching the pointer while it is over that agent's row, and a row at the bottom to +// make a new one. function PersonaList({ personas, current, @@ -153,7 +156,12 @@ function PersonaList({ onDelete: (persona: ChatPersona) => void; }) { const org = useOrgSlug(); + const unread = useUnread(); const [hovered, setHovered] = useState(takeOpeningPersona); + // Whether the persona said something the person hasn't seen: as the provider has it, which + // follows along, or as the page was loaded, until the provider has asked. + const isUnread = (persona: ChatPersona) => + persona.id !== current && (unread.loaded ? unread.unreadIds.has(persona.id) : persona.unread); return (
@@ -163,6 +171,7 @@ function PersonaList({
    {personas.map((persona) => { const last = persona.id === current ? lastOf(currentLog) : persona.lastMessage; + const fresh = isUnread(persona); const row = ( )} - {subtitle(last)} + + + {subtitle(last)} + + {fresh && ( + + )} + ); @@ -320,6 +345,7 @@ function Conversation({ const human = useHuman(); const navigate = useNavigate(); const revalidator = useRevalidator(); + const unread = useUnread(); const [log, setLog] = useState(loaded.log); const [text, setText] = useState(''); const [sending, setSending] = useState(false); @@ -352,6 +378,39 @@ function Conversation({ }; }, [readLog, log.busy]); + // The conversation is read as far as the page shows it while the page is in view: each answer + // that shows then is marked read, once, and the provider is told which conversation is open, + // so it is neither counted nor notified of. Out of view, as in a tab behind another, nothing + // is read, and what comes is told of as anywhere else. + const lastEntry = log.entries.at(-1); + const lastAnswerId = lastEntry?.kind === 'assistant' ? lastEntry.id : null; + const marked = useRef(null); + useEffect(() => { + const {setOpen, markSeen} = unread; + const read = () => { + if (document.visibilityState !== 'visible') { + setOpen(null); + return; + } + setOpen(personaId); + if (lastAnswerId === null || marked.current === lastAnswerId) { + return; + } + marked.current = lastAnswerId; + void markRead(org, personaId).then((result) => { + if (result.isOk()) { + markSeen(personaId); + } + }); + }; + read(); + document.addEventListener('visibilitychange', read); + return () => { + document.removeEventListener('visibilitychange', read); + setOpen(null); + }; + }, [org, personaId, lastAnswerId, unread]); + const send = async (event: FormEvent) => { event.preventDefault(); const message = text.trim(); From f9621c63aa11382c05906bfd85241efb8d589b8b Mon Sep 17 00:00:00 2001 From: hkonsti <45428767+hkonsti@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:44:17 +0000 Subject: [PATCH 2/4] read a conversation up to the answer the page showed, while the person looks at it, and tell of an answer once across tabs --- packages/api/src/harness/route.test.ts | 43 ++-- packages/api/src/harness/route.ts | 19 +- packages/api/src/harness/store.ts | 6 +- packages/contracts/src/harness.ts | 19 +- packages/db/prisma/schema.prisma | 6 +- packages/db/src/harness.test.ts | 82 ++++--- packages/db/src/harness.ts | 120 ++++++---- packages/desktop/README.md | 12 +- packages/desktop/src/main.ts | 15 +- packages/desktop/src/notifications.ts | 36 ++- packages/desktop/src/preload.ts | 2 +- packages/desktop/src/window.ts | 5 + packages/web/src/client/harness-client.ts | 7 +- packages/web/src/components/human-side.tsx | 11 +- packages/web/src/components/nav.test.tsx | 4 +- packages/web/src/components/nav.tsx | 4 +- .../src/components/unread-provider.test.tsx | 217 ++++++++++++++++-- .../web/src/components/unread-provider.tsx | 216 +++++++++++------ packages/web/src/hooks/use-new-release.ts | 49 ++-- packages/web/src/hooks/use-poll.ts | 52 +++++ packages/web/src/hooks/use-unread.ts | 19 +- packages/web/src/lib/desktop.ts | 2 +- packages/web/src/lib/notifications.test.ts | 37 ++- packages/web/src/lib/notifications.ts | 28 ++- packages/web/src/pages/chat-page.test.tsx | 12 +- packages/web/src/pages/chat-page.tsx | 40 +--- 26 files changed, 736 insertions(+), 327 deletions(-) create mode 100644 packages/web/src/hooks/use-poll.ts diff --git a/packages/api/src/harness/route.test.ts b/packages/api/src/harness/route.test.ts index bd23033c..c5ad181d 100644 --- a/packages/api/src/harness/route.test.ts +++ b/packages/api/src/harness/route.test.ts @@ -15,7 +15,7 @@ mock.module('./built-in-agent', () => ({ensureBuiltInAgent})); const now = new Date('2026-10-21T10:00:00Z'); const look = {nose: 0.48, noseOut: 0.35, eye: 27, eyeTheta: 55, eyeLat: 8, eyes: 2 as const}; const persona = {id: 'persona-1', name: 'Finance', look, color: 'blue', chatModel: 'claude' as 'claude' | 'glm', createdById: 'user-1'}; -const personaResponse = {id: 'persona-1', name: 'Finance', look, color: 'blue' as const, chatModel: null, mayChange: true, lastMessage: null, unread: false}; +const personaResponse = {id: 'persona-1', name: 'Finance', look, color: 'blue' as const, chatModel: null, mayChange: true, lastMessage: null, lastAnswer: null, unread: false}; const thread: ThreadRow = { id: 'thread-1', orgId: 'org-1', @@ -61,7 +61,9 @@ beforeEach(async () => { const {listPersonas, getPersona, createPersona, updatePersona, deletePersona, getMemberThread, ensureMemberThread, listShownEntries, listOpenJobs, appendPersonMessage} = store; deletePersona.mockResolvedValue(Ok(true)); requireViewer.mockResolvedValue(Ok(viewer)); - listPersonas.mockResolvedValue(Ok([{...persona, lastMessage: {id: 'e9', kind: 'assistant', text: 'Your vault\n has one entry.', createdAt: now}, unread: false}])); + listPersonas.mockResolvedValue( + Ok([{...persona, lastMessage: {kind: 'assistant', text: 'Your vault\n has one entry.', createdAt: now}, lastAnswer: {id: 'e9', text: 'Your vault\n has one entry.'}, unread: false}]), + ); getPersona.mockResolvedValue(Ok(persona)); createPersona.mockResolvedValue(Ok({...persona, id: 'persona-2'})); updatePersona.mockResolvedValue(Ok(true)); @@ -84,21 +86,21 @@ describe('the chat routes', () => { expect(store.appendPersonMessage).not.toHaveBeenCalled(); }); - test('list the organization’s personas, with their heads, whether the viewer may change each, the last thing said with each, on one line, and whether it is unread', async () => { + test('list the organization’s personas, with their heads, whether the viewer may change each, the last thing said with each and its last answer, on one line, and whether that is unread', async () => { const {handleListPersonasRoute} = routes; store.listPersonas.mockResolvedValue( Ok([ - {...persona, lastMessage: {id: 'e9', kind: 'assistant', text: 'Your vault\n has one entry.', createdAt: now}, unread: true}, - {...persona, id: 'persona-3', createdById: 'user-2', lastMessage: null, unread: false}, + {...persona, lastMessage: {kind: 'person', text: 'And\n the rest?', createdAt: now}, lastAnswer: {id: 'e9', text: 'Your vault\n has one entry.'}, unread: true}, + {...persona, id: 'persona-3', createdById: 'user-2', lastMessage: null, lastAnswer: null, unread: false}, { ...persona, id: 'persona-4', lastMessage: { - id: 'e10', kind: 'assistant', text: '## Found\n\n- **Three** invoices, see [the list](https://x.test)\n\n| Who | Sum |\n| --- | --- |\n| Dana | 40 |\n\n```\ncode\n```', createdAt: now, }, + lastAnswer: {id: 'e10', text: 'Found'}, unread: false, }, ]), @@ -109,9 +111,14 @@ describe('the chat routes', () => { expect(store.listPersonas).toHaveBeenCalledWith('org-1', 'user-1'); expect(listed).toEqual({ personas: [ - {...personaResponse, lastMessage: {id: 'e9', kind: 'assistant', text: 'Your vault has one entry.', at: '2026-10-21T10:00:00.000Z'}, unread: true}, + {...personaResponse, lastMessage: {kind: 'person', text: 'And the rest?', at: '2026-10-21T10:00:00.000Z'}, lastAnswer: {id: 'e9', text: 'Your vault has one entry.'}, unread: true}, {...personaResponse, id: 'persona-3', mayChange: false}, - {...personaResponse, id: 'persona-4', lastMessage: {id: 'e10', kind: 'assistant', text: 'Found Three invoices, see the list Who Sum Dana 40', at: '2026-10-21T10:00:00.000Z'}}, + { + ...personaResponse, + id: 'persona-4', + lastMessage: {kind: 'assistant', text: 'Found Three invoices, see the list Who Sum Dana 40', at: '2026-10-21T10:00:00.000Z'}, + lastAnswer: {id: 'e10', text: 'Found'}, + }, ], }); }); @@ -241,15 +248,25 @@ describe('the chat routes', () => { expect((await handlePostMessageRoute(request({personaId: 'persona-1'}, {text: 'x'.repeat(20_001)}))).unwrapErr().kind).toBe('parse_error'); }); - test('mark the viewer’s conversation with the persona read, begun where there was none, and say where it is gone', async () => { + test('mark the viewer’s conversation with the persona read up to the answer shown, beginning none, and say where the answer, the conversation or the persona is gone', async () => { const {handleMarkReadRoute} = routes; store.markThreadRead.mockResolvedValue(Ok(true)); - expect((await handleMarkReadRoute(request({personaId: 'persona-1'}))).isOk()).toBe(true); - expect(store.markThreadRead).toHaveBeenCalledWith('thread-1'); + expect((await handleMarkReadRoute(request({personaId: 'persona-1'}, {entryId: 'e9'}))).isOk()).toBe(true); + expect(store.markThreadRead).toHaveBeenCalledWith('thread-1', 'e9'); + + expect((await handleMarkReadRoute(request({personaId: 'persona-1'}, {}))).unwrapErr().kind).toBe('parse_error'); + + store.markThreadRead.mockResolvedValue(Ok(false)); + expect((await handleMarkReadRoute(request({personaId: 'persona-1'}, {entryId: 'elsewhere'}))).unwrapErr().kind).toBe('not_found'); + + store.getMemberThread.mockResolvedValue(Ok(null)); + expect((await handleMarkReadRoute(request({personaId: 'persona-1'}, {entryId: 'e9'}))).unwrapErr().kind).toBe('not_found'); + expect(store.ensureMemberThread).not.toHaveBeenCalled(); + expect(ensureBuiltInAgent).not.toHaveBeenCalled(); store.getPersona.mockResolvedValue(Ok(null)); - expect((await handleMarkReadRoute(request({personaId: 'persona-9'}))).unwrapErr().kind).toBe('not_found'); - expect(store.markThreadRead).toHaveBeenCalledTimes(1); + expect((await handleMarkReadRoute(request({personaId: 'persona-9'}, {entryId: 'e9'}))).unwrapErr().kind).toBe('not_found'); + expect(store.markThreadRead).toHaveBeenCalledTimes(2); }); }); diff --git a/packages/api/src/harness/route.ts b/packages/api/src/harness/route.ts index c776861c..4e40c7eb 100644 --- a/packages/api/src/harness/route.ts +++ b/packages/api/src/harness/route.ts @@ -3,10 +3,11 @@ import type {z} from 'zod'; import {ApiErr, type ApiError, Do, hasExperiment, parseSchema, requirePresent} from '@proxy/utils'; import {preview, threadState, toAvatarColor} from '@proxy/utils/harness'; -import type {EntryRow, LastMessage, Persona, PersonaRow, ThreadRow} from '@proxy/db/harness'; +import type {EntryRow, LastAnswer, LastMessage, Persona, PersonaRow, ThreadRow} from '@proxy/db/harness'; import { type ChatEntry, type ChatPersona, + markReadBodySchema, personaBodySchema, type personaAnswerSchema, type personaListSchema, @@ -51,8 +52,9 @@ function managesPersona(viewer: OrgViewer, persona: PersonaRow): boolean { } // A persona for the response; one made or changed just now has nothing said with it, and nothing unread. -function toPersona(viewer: OrgViewer, persona: PersonaRow & {lastMessage?: LastMessage | null; unread?: boolean}): ChatPersona { +function toPersona(viewer: OrgViewer, persona: PersonaRow & {lastMessage?: LastMessage | null; lastAnswer?: LastAnswer | null; unread?: boolean}): ChatPersona { const last = persona.lastMessage ?? null; + const answer = persona.lastAnswer ?? null; return { id: persona.id, name: persona.name, @@ -60,7 +62,8 @@ function toPersona(viewer: OrgViewer, persona: PersonaRow & {lastMessage?: LastM color: toAvatarColor(persona.color), chatModel: viewer.insider ? persona.chatModel : null, mayChange: managesPersona(viewer, persona), - lastMessage: last && {id: last.id, kind: last.kind, text: preview(last.text), at: last.createdAt.toISOString()}, + lastMessage: last && {kind: last.kind, text: preview(last.text), at: last.createdAt.toISOString()}, + lastAnswer: answer && {id: answer.id, text: preview(answer.text)}, unread: persona.unread ?? false, }; } @@ -201,14 +204,16 @@ function handlePostMessageRoute(store: ChatStore, request: AuthenticatedRequest) }); } -/** `POST /api/orgs/:org/chat/personas/:personaId/read`: the viewer has seen their conversation with the persona as it is now; what the persona says next is unread again. */ +/** `POST /api/orgs/:org/chat/personas/:personaId/read` with `{entryId}`: the viewer was shown their conversation with the persona up to that answer; what the persona said after it stays unread. */ function handleMarkReadRoute(store: ChatStore, request: AuthenticatedRequest): Promise> { return Do(async ($) => { const viewer = $(await requireChat(request)); + const {entryId} = $(parseSchema(markReadBodySchema, request.body)); const persona = $(await requirePersona(store, viewer, request)); - const thread = $(await requireThread(store, viewer, persona)); - if (!$(await store.markThreadRead(thread.id))) { - return $(Err(ApiErr.notFound('thread', thread.id))); + // Read, never begun: a viewer without a conversation has no answer in it to have seen. + const thread = $(await store.getMemberThread(persona.id, viewer.userId)); + if (!thread || !$(await store.markThreadRead(thread.id, entryId))) { + return $(Err(ApiErr.notFound('entry', entryId))); } }); } diff --git a/packages/api/src/harness/store.ts b/packages/api/src/harness/store.ts index 12803f03..cf21e140 100644 --- a/packages/api/src/harness/store.ts +++ b/packages/api/src/harness/store.ts @@ -6,7 +6,7 @@ import * as alerts from '@proxy/db/alerts'; import type {AlertRow, NewAlert, PollRow, WatchMatch} from '@proxy/db/alerts'; import {type ConnectionRow, getConnectionWithCredentialById, listConnectionsByIds} from '@proxy/db/connection'; import * as harness from '@proxy/db/harness'; -import type {AfterAnswer, AssistantTurn, EntryRow, FailureNote, JobRow, LastMessage, Persona, PersonaRow, SubAgentRow, ThreadRow} from '@proxy/db/harness'; +import type {AfterAnswer, AssistantTurn, EntryRow, FailureNote, JobRow, LastAnswer, LastMessage, Persona, PersonaRow, SubAgentRow, ThreadRow} from '@proxy/db/harness'; import {type AgentTaskRow, createAgentTask, getAgentTask, type NewAgentTask} from '@proxy/db/task'; import type {SignedInAgent} from '@proxy/db/agent'; @@ -39,7 +39,7 @@ export type HarnessStore = { getAgentTask: (orgId: string, agentId: string, taskId: string) => Promise>; createAgentTask: (task: NewAgentTask) => Promise>; - listPersonas: (orgId: string, userId: string) => Promise, ApiError>>; + listPersonas: (orgId: string, userId: string) => Promise, ApiError>>; ensureFirstPersona: (orgId: string) => Promise>; getPersona: (orgId: string, personaId: string) => Promise>; createPersona: (orgId: string, createdById: string, persona: Persona) => Promise>; @@ -50,7 +50,7 @@ export type HarnessStore = { listShownEntries: (threadId: string) => Promise>; listOpenJobs: (threadId: string) => Promise>; appendPersonMessage: (threadId: string, text: string) => Promise>; - markThreadRead: (threadId: string) => Promise>; + markThreadRead: (threadId: string, entryId: string) => Promise>; completeSetAlert: (job: {jobId: string; workerId: string; threadId: string; callId: string}, alert: NewAlert, answer: (alert: AlertRow) => string) => Promise>; deleteAlert: (alertId: string, owner: {threadId: string} | {orgId: string; userId: string}) => Promise>; diff --git a/packages/contracts/src/harness.ts b/packages/contracts/src/harness.ts index e6d4c907..5ff2f89b 100644 --- a/packages/contracts/src/harness.ts +++ b/packages/contracts/src/harness.ts @@ -48,12 +48,15 @@ export const chatPersonaSchema = z.object({ * The last thing said in the person's conversation with it, cut to a line, for the list; null * before anything was said. */ - lastMessage: z - .object({id: z.string(), kind: speakerSchema, text: z.string(), at: z.string()}) - .nullable(), + lastMessage: z.object({kind: speakerSchema, text: z.string(), at: z.string()}).nullable(), /** - * Whether the persona has said something the person hasn't seen: its last word in the - * conversation came after the person last read it (`POST chat/personas/:personaId/read`). + * The persona's last answer in the person's conversation with it, cut to a line, which may come + * before what the person said last; null before it answered. + */ + lastAnswer: z.object({id: z.string(), text: z.string()}).nullable(), + /** + * Whether the person hasn't seen the last answer: the page hasn't shown it to them since it + * came (`POST chat/personas/:personaId/read`). */ unread: z.boolean(), }); @@ -101,5 +104,11 @@ const MAX_MESSAGE = 20_000; /** `POST chat/personas/:personaId/messages`: what the person says to the persona; it is woken. */ export const postMessageBodySchema = z.object({text: z.string().trim().min(1).max(MAX_MESSAGE)}); +/** + * `POST chat/personas/:personaId/read`: the persona's answer the page showed the person, which + * the conversation is read up to. + */ +export const markReadBodySchema = z.object({entryId: z.string().min(1)}); + /** What the person said, as a turn of the conversation. */ export const postedMessageSchema = z.object({entry: chatEntrySchema}); diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index f7aa5147..47f6f77d 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -662,9 +662,9 @@ model AgentThread { // and made anew when it is taken up again; a conversation has none: its agent makes no calls // but to its sub-agents. taskId String? - // How far the member has read their conversation: the `seq` of the last entry they have seen, - // moved on by the Chat page while they look at it, and by what they say, never back. What the - // persona said past it is unread. A sub-agent's thread, which nobody reads, keeps 0. + // How far the member has read their conversation: the `seq` of the last answer the Chat page + // showed them while they looked at it, never back. What the persona said past it is unread. A + // sub-agent's thread, which nobody reads, keeps 0. readSeq Int @default(0) createdAt DateTime @default(now()) diff --git a/packages/db/src/harness.test.ts b/packages/db/src/harness.test.ts index a94b5876..27598539 100644 --- a/packages/db/src/harness.test.ts +++ b/packages/db/src/harness.test.ts @@ -270,45 +270,61 @@ describe.skipIf(!databaseUrl)('the harness, against the database', () => { ]); }); - test('the persona’s answer is unread until the thread is marked read, and what the person says reads everything before it', async () => { + test('the persona’s answer is unread until the thread is read up to it, never back, and what the person says after it reads nothing', async () => { const persona = unwrap(await ensureFirstPersona(orgId)); - const unreadOf = async () => - unwrap(await listPersonas(orgId, userId)).find((candidate) => candidate.id === persona.id) - ?.unread; + const listed = async () => { + const found = unwrap(await listPersonas(orgId, userId)).find( + (candidate) => candidate.id === persona.id, + ); + if (!found) { + throw new Error('the persona is gone'); + } + return found; + }; + const answer = async (workerId: string, text: string) => { + const job = unwrap(await claimJob(workerId, 60_000)); + if (!job) { + throw new Error('nothing to claim'); + } + unwrap( + await completeInference( + job.id, + workerId, + thread.id, + subTurn(text), + {plans: {}}, + readyForInference, + ), + ); + const {lastAnswer} = await listed(); + if (lastAnswer?.text !== text) { + throw new Error(`expected the answer ${text}, got ${JSON.stringify(lastAnswer)}`); + } + return lastAnswer.id; + }; unwrap(await appendPersonMessage(thread.id, 'Anything new?', readyForInference)); - expect(await unreadOf()).toBe(false); + const first = await answer('worker-r', 'Nothing new.'); + expect((await listed()).unread).toBe(true); + + unwrap(await appendPersonMessage(thread.id, 'And now?', readyForInference)); + expect(await listed()).toMatchObject({ + lastMessage: {kind: 'person', text: 'And now?'}, + lastAnswer: {id: first, text: 'Nothing new.'}, + unread: true, + }); - const job = unwrap(await claimJob('worker-r', 60_000)); - if (!job) { - throw new Error('nothing to claim'); - } - unwrap( - await completeInference( - job.id, - 'worker-r', - thread.id, - subTurn('Nothing new.'), - {plans: {}}, - readyForInference, - ), - ); - expect(await unreadOf()).toBe(true); + const second = await answer('worker-r2', 'Still nothing.'); + expect(unwrap(await markThreadRead(thread.id, first))).toBe(true); + expect((await listed()).unread).toBe(true); - expect(unwrap(await markThreadRead(thread.id))).toBe(true); - expect(await unreadOf()).toBe(false); - expect(unwrap(await markThreadRead('no-such-thread'))).toBe(false); + expect(unwrap(await markThreadRead(thread.id, second))).toBe(true); + expect((await listed()).unread).toBe(false); + expect(unwrap(await markThreadRead(thread.id, first))).toBe(true); + expect((await listed()).unread).toBe(false); - const said = unwrap(await appendPersonMessage(thread.id, 'Thanks', readyForInference)); - if (!said) { - throw new Error('the thread is gone'); - } - const read = await db.agentThread.findUniqueOrThrow({ - where: {id: thread.id}, - select: {readSeq: true}, - }); - expect(read.readSeq).toBe(said.seq); - expect(await unreadOf()).toBe(false); + expect(unwrap(await markThreadRead(thread.id, 'no-such-entry'))).toBe(false); + expect(unwrap(await markThreadRead('no-such-thread', second))).toBe(false); }); test('a finish with nothing written is a done event too', async () => { diff --git a/packages/db/src/harness.ts b/packages/db/src/harness.ts index 1140618a..19cdccf8 100644 --- a/packages/db/src/harness.ts +++ b/packages/db/src/harness.ts @@ -1,8 +1,8 @@ -import type { - AgentEntryKind, - AgentJobEventKind, - AgentJobKind, - AgentJobStatus, +import { + type AgentEntryKind, + type AgentJobEventKind, + type AgentJobKind, + type AgentJobStatus, Prisma, } from '@prisma/client'; import {Err, Ok, type Result} from 'ts-results-es'; @@ -285,51 +285,86 @@ export function toEntry(entry: EntryRecord): EntryRow { /** * The organization's personas, oldest first, with the last thing said in the member's conversation - * with each, and whether it is unread: the persona's, said past where the member has read to. + * with each, the persona's last answer in it, and whether that is unread: said past where the + * member has read to. */ export async function listPersonas( orgId: string, userId: string, ): Promise< - Result, ApiError> + Result< + Array< + PersonaRow & {lastMessage: LastMessage | null; lastAnswer: LastAnswer | null; unread: boolean} + >, + ApiError + > > { return ( await wrapDb(() => - db.persona.findMany({ - where: {orgId}, - select: { - ...personaSelect, - threads: { - where: {userId, parentThreadId: null}, + // One snapshot for both, so an answer landing between them can't leave the last thing said + // newer than the last answer, or the other way round. + db.$transaction( + [ + db.persona.findMany({ + where: {orgId}, + select: { + ...personaSelect, + threads: { + where: {userId, parentThreadId: null}, + select: { + // The agent's turns that only made calls say nothing, so the last with words. + entries: { + where: {kind: {in: ['person', 'assistant']}, text: {not: ''}}, + orderBy: {seq: 'desc'}, + take: 1, + select: {kind: true, text: true, createdAt: true}, + }, + }, + }, + }, + orderBy: {createdAt: 'asc'}, + }), + // The persona's last answer in each of the member's conversations, which may come + // before what the person said last, and how far they have read it. + db.agentThread.findMany({ + where: {orgId, userId, parentThreadId: null}, select: { + personaId: true, readSeq: true, - // The agent's turns that only made calls say nothing, so the last with words. entries: { - where: {kind: {in: ['person', 'assistant']}, text: {not: ''}}, + where: {kind: 'assistant', text: {not: ''}}, orderBy: {seq: 'desc'}, take: 1, - select: {id: true, seq: true, kind: true, text: true, createdAt: true}, + select: {id: true, seq: true, text: true}, }, }, - }, - }, - orderBy: {createdAt: 'asc'}, - }), + }), + ], + {isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead}, + ), ) - ).map((personas) => - personas.map(({threads, ...persona}) => { - const thread = threads[0]; + ).map(([personas, threads]) => { + const answers = new Map( + threads.flatMap(({personaId, readSeq, entries: [answer]}) => + answer + ? [[personaId, {id: answer.id, text: answer.text, unread: answer.seq > readSeq}] as const] + : [], + ), + ); + return personas.map(({threads: [thread], ...persona}) => { const last = thread?.entries[0]; - if (!thread || !last || (last.kind !== 'person' && last.kind !== 'assistant')) { - return {...toPersona(persona), lastMessage: null, unread: false}; - } + const answer = answers.get(persona.id); return { ...toPersona(persona), - lastMessage: {id: last.id, kind: last.kind, text: last.text, createdAt: last.createdAt}, - unread: last.kind === 'assistant' && last.seq > thread.readSeq, + lastMessage: + last && (last.kind === 'person' || last.kind === 'assistant') + ? {kind: last.kind, text: last.text, createdAt: last.createdAt} + : null, + lastAnswer: answer ? {id: answer.id, text: answer.text} : null, + unread: answer?.unread ?? false, }; - }), - ); + }); + }); } /** @@ -483,17 +518,23 @@ export async function ensureMemberThread(data: { } /** The last thing said in a thread, by the person or the agent. */ -export type LastMessage = {id: string; kind: 'person' | 'assistant'; text: string; createdAt: Date}; +export type LastMessage = {kind: 'person' | 'assistant'; text: string; createdAt: Date}; + +/** The persona's last answer in a conversation, which the member may not have seen. */ +export type LastAnswer = {id: string; text: string}; /** - * Marks the thread read to its end: everything in it now, so what the persona says next is - * unread. `Ok(false)` means no such thread. + * Marks the thread read up to the entry, as far as the member was shown it, never back: what the + * persona said after it stays unread. `Ok(false)` means no such entry in the thread. */ -export async function markThreadRead(threadId: string): Promise> { +export async function markThreadRead( + threadId: string, + entryId: string, +): Promise> { return ( await wrapDb( () => - db.$executeRaw`UPDATE "AgentThread" SET "readSeq" = GREATEST("readSeq", COALESCE((SELECT MAX("seq") FROM "AgentEntry" WHERE "threadId" = ${threadId}), 0)) WHERE "id" = ${threadId}`, + db.$executeRaw`UPDATE "AgentThread" AS t SET "readSeq" = GREATEST(t."readSeq", e."seq") FROM "AgentEntry" AS e WHERE t."id" = ${threadId} AND e."id" = ${entryId} AND e."threadId" = t."id"`, ) ).map((count) => count > 0); } @@ -791,10 +832,7 @@ async function endTurn( await tellParent(tx, thread, stop.stopped, jobId, wakes); } -/** - * Lands what the person said, and wakes the agent where the log `wakes` it. The person has read - * everything before what they say, so the thread is read to it. `Ok(null)` means no such thread. - */ +/** Lands what the person said, and wakes the agent where the log `wakes` it. `Ok(null)` means no such thread. */ export async function appendPersonMessage( threadId: string, text: string, @@ -806,9 +844,7 @@ export async function appendPersonMessage( if (!thread) { return null; } - const entry = await appendEntry(tx, thread, {kind: 'person', text}, wakes); - await tx.$executeRaw`UPDATE "AgentThread" SET "readSeq" = GREATEST("readSeq", ${entry.seq}) WHERE "id" = ${threadId}`; - return entry; + return appendEntry(tx, thread, {kind: 'person', text}, wakes); }), ); } diff --git a/packages/desktop/README.md b/packages/desktop/README.md index 749e3b7b..06cd7dc2 100644 --- a/packages/desktop/README.md +++ b/packages/desktop/README.md @@ -17,11 +17,13 @@ everything but the app's own origin out of the window: links out of the app open (`src/preload.ts`, `window.personalAgentProxyDesktop` to the web app), which shows its release notice with a restart button. Put off, the update is installed when the app next quits. - Notifications (`src/notifications.ts`): the web app asks for one through the preload, with the - agent's name, what it said and the page of the app it is on, when an agent answers in Chat - while the person is elsewhere and they have turned notifications on; the app shows it as the - system's, and, clicked, focuses the window and hands the page back to the web app, which opens - it, or opens a new window on it where there is none. The web app sets the count of conversations - with something unread on the app's badge the same way. + agent's name, what it said, the page of the app it is on and a tag, one per conversation, when + an agent answers in Chat while the person is elsewhere and they have turned notifications on; + the app shows it as the system's, closing the one of the same tag before it, and, clicked, + focuses the window and hands the page back to the web app, which opens it, or loads the page + where the window has none of the web app's, as on the sign-in and offline pages, or opens a new + window on it where there is none. The web app sets the count of conversations with something + unread on the app's badge the same way. - Signing in (`src/auth.ts`, `packages/api/src/auth/desktop.ts`): the app keeps a PKCE verifier and opens the browser at `/login?callbackUrl=/desktop-auth?challenge=…`. Signed in there, the `/desktop-auth` page mints a one-time code bound to the challenge and hands it back through diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index cb79c5d4..c34595f2 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -8,7 +8,7 @@ import {DEEP_LINK_SCHEME, findDeepLink, parseDeepLink, type DeepLink} from './de import {DESKTOP_VERSION} from './env'; import {initNotifications} from './notifications'; import {initUpdates} from './updates'; -import {createMainWindow, loadApp, showSignIn} from './window'; +import {createMainWindow, isAppPage, loadApp, showSignIn} from './window'; // The desktop app: a window around the web app, hosted, with nothing of it bundled. The main // process keeps the window, signs in through the browser, takes the app's links: the browser @@ -47,15 +47,18 @@ function focusWindow(): BrowserWindow { return win; } -// Opens a page of the app, as a notification clicked asks: in the window as it is, which moves -// there itself, or in a new one where there is none, as on macOS after the window was closed. +// Opens a page of the app, as a notification clicked asks: in the window's page of the app, which +// moves there itself, or loaded where the window has none, as on macOS after the window was closed, +// or on the sign-in and offline pages, which can't. function openPath(path: string): void { const open = currentWindow(); - if (!open) { - loadApp(focusWindow(), path); + const showsApp = open !== null && isAppPage(open.webContents.getURL()); + const win = focusWindow(); + if (!showsApp) { + loadApp(win, path); return; } - focusWindow().webContents.send(OPEN_PATH_CHANNEL, path); + win.webContents.send(OPEN_PATH_CHANNEL, path); } async function onDeepLink(link: DeepLink): Promise { diff --git a/packages/desktop/src/notifications.ts b/packages/desktop/src/notifications.ts index 43509f38..cf9846be 100644 --- a/packages/desktop/src/notifications.ts +++ b/packages/desktop/src/notifications.ts @@ -8,7 +8,26 @@ import {fromApp} from './ipc'; // shown by the system, and the count of conversations with something unread on the app's badge. // A notification clicked opens its conversation, the page it names, in the window. -const noticeSchema = z.object({title: z.string(), body: z.string(), path: z.string()}); +// A page of the app, by its path: one slash at the start, so it can't name another host. The tag, +// one per conversation, is optional while pages from before it still ask. +const noticeSchema = z.object({ + title: z.string(), + body: z.string(), + path: z.string().regex(/^\/(?!\/)/), + tag: z.string().optional(), +}); + +// The notifications on screen, held until they close: one only the system holds is collected, +// and its click with it. One per tag: a newer notice of the same tag closes the one showing. +const showing = new Set(); +const tagged = new Map(); + +function forget(shown: Notification, tag: string | undefined): void { + showing.delete(shown); + if (tag !== undefined && tagged.get(tag) === shown) { + tagged.delete(tag); + } +} export function initNotifications(openPath: (path: string) => void): void { ipcMain.on(NOTIFY_CHANNEL, (event, raw) => { @@ -16,8 +35,19 @@ export function initNotifications(openPath: (path: string) => void): void { if (!fromApp(event) || !notice.success || !Notification.isSupported()) { return; } - const shown = new Notification({title: notice.data.title, body: notice.data.body}); - shown.on('click', () => openPath(notice.data.path)); + const {title, body, path, tag} = notice.data; + const shown = new Notification({title, body}); + showing.add(shown); + shown.on('close', () => forget(shown, tag)); + shown.on('click', () => { + forget(shown, tag); + openPath(path); + }); + if (tag !== undefined) { + const before = tagged.get(tag); + tagged.set(tag, shown); + before?.close(); + } shown.show(); }); diff --git a/packages/desktop/src/preload.ts b/packages/desktop/src/preload.ts index aeee8e6e..030b7511 100644 --- a/packages/desktop/src/preload.ts +++ b/packages/desktop/src/preload.ts @@ -60,7 +60,7 @@ contextBridge.exposeInMainWorld('personalAgentProxyDesktop', { return () => ipcRenderer.removeListener(UPDATE_READY_CHANNEL, handler); }, restart: (): void => ipcRenderer.send(RESTART_CHANNEL), - notify: (notice: {title: string; body: string; path: string}): void => + notify: (notice: {title: string; body: string; path: string; tag: string}): void => ipcRenderer.send(NOTIFY_CHANNEL, notice), onOpenPath: (listener: (path: string) => void): (() => void) => { const handler = (_event: IpcRendererEvent, path: string) => listener(path); diff --git a/packages/desktop/src/window.ts b/packages/desktop/src/window.ts index fa673059..64486101 100644 --- a/packages/desktop/src/window.ts +++ b/packages/desktop/src/window.ts @@ -52,6 +52,11 @@ function isAllowed(url: URL): boolean { return url.protocol === 'data:' || url.origin === APP_ORIGIN; } +/** Whether the window is on a page of the web app, rather than a splash page or none. */ +export function isAppPage(raw: string): boolean { + return urlOf(raw)?.origin === APP_ORIGIN; +} + // The person's sign-in page. Not the agents' (/agent/login), which works in the window as it is. function isLoginPage(url: URL): boolean { return url.origin === APP_ORIGIN && url.pathname === '/login'; diff --git a/packages/web/src/client/harness-client.ts b/packages/web/src/client/harness-client.ts index 61c98a67..7556a4e9 100644 --- a/packages/web/src/client/harness-client.ts +++ b/packages/web/src/client/harness-client.ts @@ -1,4 +1,5 @@ import { + type markReadBodySchema, personaAnswerSchema, personaListSchema, type Persona, @@ -47,6 +48,8 @@ export function postMessage(org: string, personaId: string, text: string) { ); } -export function markRead(org: string, personaId: string) { - return apiSend('POST', `${orgApi(org)}/chat/personas/${personaId}/read`); +export function markRead(org: string, personaId: string, entryId: string) { + return apiSend('POST', `${orgApi(org)}/chat/personas/${personaId}/read`, { + entryId, + } satisfies z.input); } diff --git a/packages/web/src/components/human-side.tsx b/packages/web/src/components/human-side.tsx index aa040e1d..143a0bd4 100644 --- a/packages/web/src/components/human-side.tsx +++ b/packages/web/src/components/human-side.tsx @@ -1,18 +1,21 @@ import {Outlet} from 'react-router'; import {SidebarProvider} from '@proxy/ui/components/sidebar'; import {UnreadProvider} from '@/components/unread-provider'; +import {useHuman} from '@/hooks/use-human'; import {useOrgSlug} from '@/hooks/use-org'; // The human side, behind its sign-in (the route's loader). The sidebar is always a rail on // desktop. Keyed by the organization, so a page switched to another's starts over, rather than -// keeping what was typed, picked or open in the one before; what is unread in the organization's -// conversations is kept around every page of it. +// keeping what was typed, picked or open in the one before; what is unread in the conversations +// of the sidebar's organization, the person's first on their account page, is kept around every +// page. export function HumanSide() { const org = useOrgSlug(); + const human = useHuman(); return ( - - + + ); diff --git a/packages/web/src/components/nav.test.tsx b/packages/web/src/components/nav.test.tsx index 678888be..61c30502 100644 --- a/packages/web/src/components/nav.test.tsx +++ b/packages/web/src/components/nav.test.tsx @@ -20,9 +20,7 @@ function unreadOf(ids: string[]): Unread { return { loaded: true, unreadIds: new Set(ids), - count: ids.length, - setOpen: () => undefined, - markSeen: () => undefined, + setViewing: () => undefined, }; } diff --git a/packages/web/src/components/nav.tsx b/packages/web/src/components/nav.tsx index e4ec0875..c05e6549 100644 --- a/packages/web/src/components/nav.tsx +++ b/packages/web/src/components/nav.tsx @@ -117,8 +117,8 @@ export function AppSidebar() { {destination.label} - {destination.to === CHAT && unread.count > 0 && ( - + {destination.to === CHAT && unread.unreadIds.size > 0 && ( + )} ))} diff --git a/packages/web/src/components/unread-provider.test.tsx b/packages/web/src/components/unread-provider.test.tsx index defea4a5..a41710bd 100644 --- a/packages/web/src/components/unread-provider.test.tsx +++ b/packages/web/src/components/unread-provider.test.tsx @@ -10,9 +10,13 @@ import {useUnread} from '@/hooks/use-unread'; import {testHuman, testOrg} from '@/test-org'; const realFetch = globalThis.fetch; +const realHasFocus = document.hasFocus.bind(document); 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}; +const LIST = '/api/orgs/acme/chat/personas'; +const READ_GENERAL = '/api/orgs/acme/chat/personas/general/read'; + function persona(id: string, unread: boolean): ChatPersona { return { id, @@ -22,59 +26,104 @@ function persona(id: string, unread: boolean): ChatPersona { color: 'blue', chatModel: null, unread, - lastMessage: {id: `${id}-last`, kind: 'assistant', text: 'Done.', at: '2026-10-21T10:00:00Z'}, + lastMessage: {kind: 'person', text: 'Thanks', at: '2026-10-21T10:00:00Z'}, + lastAnswer: {id: `${id}-answer`, text: 'Done.'}, }; } -// A page that shows what the provider has, and opens a conversation on request. +// The api: the personas as `listed` has them, and a read that lands; `read` is called with what +// it was sent. +function serve(listed: () => ChatPersona[], read: (body: string) => void = () => undefined) { + fetchMock.mockImplementation((input, init) => { + if (input.endsWith('/read')) { + read(String(init?.body)); + return Promise.resolve(new Response(null, {status: 204})); + } + return Promise.resolve(Response.json({personas: listed()})); + }); +} + +function readCalls() { + return fetchMock.mock.calls.filter(([url]) => url === READ_GENERAL); +} + +// A page that shows what the provider has, and shows a conversation, or none, on request. function Consumer() { const unread = useUnread(); return (
    - {unread.count} + {unread.unreadIds.size} {[...unread.unreadIds].join(',')} - -
    ); } -function renderProvider(experiments: Experiment[] = ['harness']) { +function renderProvider(experiments: Experiment[] = ['harness'], entry = '/acme') { const router = createMemoryRouter( [ { id: 'human', loader: () => testHuman({...testOrg, experiments}), element: , - children: [{path: '/:org', element: }], + children: [ + {path: '/account', element: }, + {path: '/:org', element: }, + ], }, ], - {initialEntries: ['/acme']}, + {initialEntries: [entry]}, ); return render(); } const setBadge = mock((_count: number) => undefined); +const notifyDesktop = mock( + (_notice: {title: string; body: string; path: string; tag: string}) => undefined, +); +const realNow = Date.now; +// How far the clock is moved on, so a poll asked for by the page coming into view is not too soon. +let later = 0; + +function moveOn(ms: number) { + later += ms; +} + +function comeBack() { + moveOn(6000); + act(() => { + window.dispatchEvent(new Event('focus')); + }); +} + +function listCalls() { + return fetchMock.mock.calls.filter(([url]) => url === LIST); +} beforeEach(() => { fetchMock.mockReset(); - fetchMock.mockImplementation(() => - Promise.resolve( - Response.json({personas: [persona('general', true), persona('finance', true)]}), - ), - ); + serve(() => [persona('general', true), persona('finance', true)]); globalThis.fetch = ((input: string, init?: RequestInit) => fetchMock(input, init)) as typeof fetch; + document.hasFocus = () => true; setBadge.mockClear(); + notifyDesktop.mockClear(); + later = 0; + Date.now = () => realNow() + later; + localStorage.clear(); window.personalAgentProxyDesktop = { updateReady: () => Promise.resolve(null), onUpdateReady: () => () => undefined, restart: () => undefined, - notify: () => undefined, + notify: notifyDesktop, onOpenPath: () => () => undefined, setBadge, }; @@ -84,32 +133,156 @@ beforeEach(() => { afterEach(() => { cleanup(); globalThis.fetch = realFetch; + document.hasFocus = realHasFocus; + Date.now = realNow; delete window.personalAgentProxyDesktop; }); describe('UnreadProvider', () => { - test('asks for the conversations, counts those with something unread, in the title and on the badge, less the one open, and one seen', async () => { + test('asks for the conversations, counts those with something unread, in the title and on the badge, less the one looked at, which it reads up to the answer shown', async () => { + let generalRead = false; + const bodies: string[] = []; + serve( + () => [persona('general', !generalRead), persona('finance', true)], + (body) => { + bodies.push(body); + generalRead = true; + }, + ); renderProvider(); await waitFor(() => { expect(screen.getByTestId('count').textContent).toBe('2'); }); - expect(fetchMock).toHaveBeenCalledWith('/api/orgs/acme/chat/personas', expect.anything()); + expect(fetchMock).toHaveBeenCalledWith(LIST, expect.anything()); expect(screen.getByTestId('ids').textContent).toBe('general,finance'); expect(document.title).toBe('(2) Personal Agent Proxy'); expect(setBadge).toHaveBeenLastCalledWith(2); act(() => { - screen.getByRole('button', {name: 'Open General'}).click(); + screen.getByRole('button', {name: 'View General'}).click(); }); expect(screen.getByTestId('count').textContent).toBe('1'); expect(screen.getByTestId('ids').textContent).toBe('finance'); + await waitFor(() => { + expect(bodies).toEqual([JSON.stringify({entryId: 'general-answer'})]); + }); + + // Once the read lands the conversations are asked about again, so leaving leaves it read. + await waitFor(() => { + expect(fetchMock.mock.calls.filter(([url]) => url === LIST)).toHaveLength(2); + }); + act(() => { + screen.getByRole('button', {name: 'Leave'}).click(); + }); + await waitFor(() => { + expect(screen.getByTestId('ids').textContent).toBe('finance'); + }); + expect(document.title).toBe('(1) Personal Agent Proxy'); + expect(setBadge).toHaveBeenLastCalledWith(1); + expect(readCalls()).toHaveLength(1); + }); + test('reads nothing, and counts the conversation shown, while the window is not the one the person is in', async () => { + renderProvider(); + await waitFor(() => { + expect(screen.getByTestId('count').textContent).toBe('2'); + }); + + document.hasFocus = () => false; act(() => { - screen.getByRole('button', {name: 'Seen Finance'}).click(); + window.dispatchEvent(new Event('blur')); }); - expect(screen.getByTestId('count').textContent).toBe('0'); - expect(document.title).toBe('Personal Agent Proxy'); + act(() => { + screen.getByRole('button', {name: 'View General'}).click(); + }); + expect(screen.getByTestId('count').textContent).toBe('2'); + expect(readCalls()).toHaveLength(0); + + document.hasFocus = () => true; + act(() => { + window.dispatchEvent(new Event('focus')); + }); + expect(screen.getByTestId('count').textContent).toBe('1'); + await waitFor(() => { + expect(readCalls()).toHaveLength(1); + }); + }); + + test('sends no read of an answer the api has read already', async () => { + serve(() => [persona('general', false), persona('finance', true)]); + renderProvider(); + await waitFor(() => { + expect(screen.getByTestId('count').textContent).toBe('1'); + }); + + act(() => { + screen.getByRole('button', {name: 'View General'}).click(); + }); + await waitFor(() => { + expect(fetchMock.mock.calls.filter(([url]) => url === LIST)).toHaveLength(1); + }); + expect(readCalls()).toHaveLength(0); + }); + + test('keeps the count of the organization in the sidebar on the person’s account page', async () => { + renderProvider(['harness'], '/account'); + + await waitFor(() => { + expect(screen.getByTestId('count').textContent).toBe('2'); + }); + expect(fetchMock).toHaveBeenCalledWith(LIST, expect.anything()); + expect(setBadge).toHaveBeenLastCalledWith(2); + }); + + test('tells of an answer that came once it has stayed unread a moment, and of none read meanwhile, as in another window', async () => { + localStorage.setItem('proxy-notifications', 'on'); + let answers = {general: 'general-1', finance: 'finance-1'}; + let financeRead = false; + serve(() => [ + {...persona('general', true), lastAnswer: {id: answers.general, text: 'Filed.'}}, + {...persona('finance', !financeRead), lastAnswer: {id: answers.finance, text: 'Paid.'}}, + ]); + renderProvider(); + await waitFor(() => { + expect(listCalls()).toHaveLength(1); + }); + + // What was unread when the page opened is not told of. + answers = {general: 'general-2', finance: 'finance-2'}; + comeBack(); + await waitFor(() => { + expect(listCalls()).toHaveLength(2); + }); + expect(notifyDesktop).not.toHaveBeenCalled(); + + // Finance's answer is read elsewhere meanwhile; General's stays unread and is told of. + financeRead = true; + comeBack(); + await waitFor(() => { + expect(notifyDesktop).toHaveBeenCalledTimes(1); + }); + expect(notifyDesktop).toHaveBeenCalledWith({ + title: 'general', + body: 'Filed.', + path: '/acme/chat/general', + tag: 'general', + }); + + comeBack(); + await waitFor(() => { + expect(listCalls()).toHaveLength(4); + }); + expect(notifyDesktop).toHaveBeenCalledTimes(1); + }); + + test('takes the count off the badge when it goes, as when the person signs out', async () => { + const {unmount} = renderProvider(); + await waitFor(() => { + expect(setBadge).toHaveBeenLastCalledWith(2); + }); + + unmount(); expect(setBadge).toHaveBeenLastCalledWith(0); }); diff --git a/packages/web/src/components/unread-provider.tsx b/packages/web/src/components/unread-provider.tsx index 4bf83783..72468530 100644 --- a/packages/web/src/components/unread-provider.tsx +++ b/packages/web/src/components/unread-provider.tsx @@ -1,129 +1,192 @@ import type {ChatPersona} from '@proxy/contracts/harness'; import {type ReactNode, useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {useNavigate} from 'react-router'; -import {listPersonas} from '@/client/harness-client'; +import {listPersonas, markRead} from '@/client/harness-client'; import {useExperiments} from '@/hooks/use-experiment'; -import {useOrgSlug} from '@/hooks/use-org'; -import {type Unread, UnreadContext} from '@/hooks/use-unread'; +import {useHuman} from '@/hooks/use-human'; +import {usePoll} from '@/hooks/use-poll'; +import {type Unread, UnreadContext, type Viewing} from '@/hooks/use-unread'; import {desktopBridge} from '@/lib/desktop'; import {notify} from '@/lib/notifications'; -// What the organization's agents said that the person hasn't seen, asked for every so often from -// every page, and when the page comes back into view: the count on the sidebar's Chat, in the -// window's title and on the desktop app's badge, the mark on each conversation in Chat's list, and -// a notification for what came while the person was elsewhere. The conversation the Chat page -// shows while it is in view is never unread or notified of: the page marks it read as it reads it. +// What the organization's agents said that the person hasn't seen, as the api keeps it, asked for +// every so often from every page, when the page comes back into view, and after the person read +// something: the count on the sidebar's Chat, in the window's title and on the desktop app's +// badge, the mark on each conversation in Chat's list, and a notification for what came while the +// person was elsewhere. The Chat page says which conversation it shows, and up to which answer; +// while the person looks at it, in view and in the window they are in, it is neither counted nor +// notified of, and that answer is sent as read. An answer is told of once it has stayed unread a +// moment, asked about again, so a window the person reads it in, another tab or the desktop app, +// has marked it read first. const POLL_MS = 20 * 1000; const VISIBLE_POLL_GAP_MS = 5 * 1000; +const CONFIRM_MS = 5 * 1000; -// Whether the persona said something the person hasn't seen, that a notice would be of. -function unreadFrom(persona: ChatPersona): ChatPersona['lastMessage'] { - if (!persona.unread || persona.lastMessage?.kind !== 'assistant') { - return null; - } - return persona.lastMessage; +// Whether the person is looking at the page: in view, and in the window they are in. +function isAttending(): boolean { + return document.visibilityState === 'visible' && document.hasFocus(); +} + +// An answer first seen unread, at a time, waiting to be told of. +type Waiting = {answerId: string; since: number}; + +// Whether a poll's personas are the ones kept, so nothing renders anew for them. +function samePersonas(kept: ChatPersona[] | null, listed: ChatPersona[]): boolean { + return kept !== null && JSON.stringify(kept) === JSON.stringify(listed); } export function UnreadProvider({children}: {children: ReactNode}) { - const org = useOrgSlug(); + // The sidebar's organization: the address's, or on the person's account page, their first. + const org = useHuman()?.org.slug ?? ''; const navigate = useNavigate(); const experiments = useExperiments(); const enabled = org !== '' && experiments.includes('harness'); const [personas, setPersonas] = useState(null); - const [open, setOpenState] = useState(null); - // The open conversation as the poll, outside render, reads it. - const openRef = useRef(null); - const setOpen = useCallback((personaId: string | null) => { - openRef.current = personaId; - setOpenState(personaId); - }, []); - // What each persona was last told of, by the message: the first answer only sets this, so a - // page opened on old news doesn't repeat it, and everything since is told of once. + const [viewing, setViewing] = useState(null); + const [attending, setAttending] = useState(isAttending); + // The conversation the person is looking at, and as the poll, outside render, reads it. + const looking = attending ? viewing : null; + const lookingRef = useRef(looking); + useEffect(() => { + lookingRef.current = looking; + }, [looking]); + // What each persona was last told of, by the answer: the first poll only sets this, so a page + // opened on old news doesn't repeat it, and everything since is told of once. const told = useRef | null>(null); + // The answers waiting to be told of, by the persona, and when to ask about them again. + const waiting = useRef(new Map()); + const [confirmAt, setConfirmAt] = useState(null); + // Which poll was asked for last: one asked for before it that answers after it is dropped, so a + // poll begun before a read can't put back what was unread. + const latestPoll = useRef(0); + // Whether the provider is still there: a poll or a read that lands after it is gone is dropped. + const alive = useRef(true); + useEffect(() => { + alive.current = true; + return () => { + alive.current = false; + }; + }, []); const poll = useCallback(async () => { + if (!alive.current) { + return; + } + latestPoll.current += 1; + const asked = latestPoll.current; const result = await listPersonas(org); - if (result.isErr()) { + if (!alive.current || asked !== latestPoll.current || result.isErr()) { return; } const listed = result.value.personas; - setPersonas(listed); - const known = told.current; - told.current = new Map( - listed.flatMap((persona) => { - const last = unreadFrom(persona); - return last ? [[persona.id, last.id] as const] : []; - }), + setPersonas((kept) => (samePersonas(kept, listed) ? kept : listed)); + const unread = listed.flatMap((persona) => + persona.unread && persona.lastAnswer ? [{persona, answer: persona.lastAnswer}] : [], ); + const known = told.current; if (!known) { + told.current = new Map(unread.map(({persona, answer}) => [persona.id, answer.id])); return; } - for (const persona of listed) { - const last = unreadFrom(persona); - if (!last || persona.id === openRef.current || known.get(persona.id) === last.id) { + const now = Date.now(); + const nextTold = new Map(); + const nextWaiting = new Map(); + for (const {persona, answer} of unread) { + if (known.get(persona.id) === answer.id || persona.id === lookingRef.current?.personaId) { + nextTold.set(persona.id, answer.id); + continue; + } + const before = waiting.current.get(persona.id); + const since = before?.answerId === answer.id ? before.since : now; + if (now - since < CONFIRM_MS) { + nextWaiting.set(persona.id, {answerId: answer.id, since}); continue; } + nextTold.set(persona.id, answer.id); notify( - {title: persona.name, body: last.text, path: `/${org}/chat/${persona.id}`}, - persona.id, + { + title: persona.name, + body: answer.text, + path: `/${org}/chat/${persona.id}`, + tag: persona.id, + id: answer.id, + }, (path) => void navigate(path), ); } + told.current = nextTold; + waiting.current = nextWaiting; + const since = [...nextWaiting.values()].map((answer) => answer.since); + setConfirmAt(since.length === 0 ? null : Math.min(...since) + CONFIRM_MS); }, [org, navigate]); useEffect(() => { - if (!enabled) { + if (confirmAt === null) { return; } - let lastPoll = Date.now(); - const ask = () => { - lastPoll = Date.now(); - void poll(); - }; - const onVisible = () => { - if (document.visibilityState !== 'visible' || Date.now() - lastPoll < VISIBLE_POLL_GAP_MS) { - return; - } - ask(); + const timer = setTimeout(() => void poll(), Math.max(0, confirmAt - Date.now())); + return () => { + clearTimeout(timer); }; - ask(); - const timer = setInterval(ask, POLL_MS); - document.addEventListener('visibilitychange', onVisible); - window.addEventListener('focus', onVisible); + }, [confirmAt, poll]); + + useEffect(() => { + const onChange = () => setAttending(isAttending()); + document.addEventListener('visibilitychange', onChange); + window.addEventListener('focus', onChange); + window.addEventListener('blur', onChange); return () => { - clearInterval(timer); - document.removeEventListener('visibilitychange', onVisible); - window.removeEventListener('focus', onVisible); + document.removeEventListener('visibilitychange', onChange); + window.removeEventListener('focus', onChange); + window.removeEventListener('blur', onChange); }; - }, [enabled, poll]); + }, []); - // A notification clicked in the desktop app opens its conversation. - useEffect(() => desktopBridge()?.onOpenPath?.((path) => void navigate(path)), [navigate]); + const ask = useCallback(() => void poll(), [poll]); + usePoll(ask, {enabled, intervalMs: POLL_MS, visibleGapMs: VISIBLE_POLL_GAP_MS, immediate: true}); - const markSeen = useCallback((personaId: string) => { - setPersonas((current) => { - if (!current?.some((persona) => persona.id === personaId && persona.unread)) { - return current; + // The answer the person is looking at is read: sent once, unless the api already has it read, + // and asked about again once it lands. One that failed to send is sent again after the next + // poll. + const sent = useRef(null); + useEffect(() => { + if (!enabled || !looking?.answerId) { + return; + } + const {personaId, answerId} = looking; + const known = personas?.find((persona) => persona.id === personaId); + const read = known !== undefined && !known.unread && known.lastAnswer?.id === answerId; + if (read || sent.current === answerId) { + return; + } + sent.current = answerId; + void markRead(org, personaId, answerId).then((result) => { + if (result.isErr()) { + sent.current = null; + return; } - return current.map((persona) => - persona.id === personaId ? {...persona, unread: false} : persona, - ); + void poll(); }); - }, []); + }, [enabled, org, looking, personas, poll]); + // A notification clicked in the desktop app opens its conversation. + useEffect(() => desktopBridge()?.onOpenPath?.((path) => void navigate(path)), [navigate]); + + const lookingAt = looking?.personaId ?? null; const unreadIds = useMemo( () => new Set( (personas ?? []) - .filter((persona) => persona.unread && persona.id !== open) + .filter((persona) => persona.unread && persona.id !== lookingAt) .map((persona) => persona.id), ), - [personas, open], + [personas, lookingAt], ); const count = unreadIds.size; - // The count in the window's title and on the desktop app's badge, while there is one. + // The count in the window's title and on the desktop app's badge, while there is one, and + // while the provider is there: signed out, the badge has no count. useEffect(() => { const title = document.title; if (count === 0) { @@ -135,12 +198,17 @@ export function UnreadProvider({children}: {children: ReactNode}) { }; }, [count]); useEffect(() => { - desktopBridge()?.setBadge?.(count); + const bridge = desktopBridge(); + bridge?.setBadge?.(count); + if (count === 0) { + return; + } + return () => { + bridge?.setBadge?.(0); + }; }, [count]); - const value = useMemo( - () => ({loaded: personas !== null, unreadIds, count, setOpen, markSeen}), - [personas, unreadIds, count, setOpen, markSeen], - ); + const loaded = personas !== null; + const value = useMemo(() => ({loaded, unreadIds, setViewing}), [loaded, unreadIds]); return {children}; } diff --git a/packages/web/src/hooks/use-new-release.ts b/packages/web/src/hooks/use-new-release.ts index 4c5b1b32..62ade4aa 100644 --- a/packages/web/src/hooks/use-new-release.ts +++ b/packages/web/src/hooks/use-new-release.ts @@ -1,4 +1,5 @@ -import {useEffect, useState} from 'react'; +import {useCallback, useState} from 'react'; +import {usePoll} from '@/hooks/use-poll'; import {hasNewRelease} from '@/lib/release'; // Whether the server has a newer release of the web app than the page: asked every so often, @@ -10,43 +11,19 @@ const VISIBLE_CHECK_GAP_MS = 60 * 1000; export function useNewRelease(): boolean { const [newRelease, setNewRelease] = useState(false); - useEffect(() => { - if (newRelease) { - return; - } - - let cancelled = false; - let lastCheck = Date.now(); - - const check = async () => { - lastCheck = Date.now(); - const found = await hasNewRelease(document); - if (!cancelled && found) { + const check = useCallback((signal: AbortSignal) => { + void hasNewRelease(document).then((found) => { + if (!signal.aborted && found) { setNewRelease(true); } - }; - - const onVisible = () => { - if (document.visibilityState !== 'visible') { - return; - } - if (Date.now() - lastCheck < VISIBLE_CHECK_GAP_MS) { - return; - } - void check(); - }; - - const timer = setInterval(() => void check(), CHECK_INTERVAL_MS); - document.addEventListener('visibilitychange', onVisible); - window.addEventListener('focus', onVisible); - - return () => { - cancelled = true; - clearInterval(timer); - document.removeEventListener('visibilitychange', onVisible); - window.removeEventListener('focus', onVisible); - }; - }, [newRelease]); + }); + }, []); + usePoll(check, { + enabled: !newRelease, + intervalMs: CHECK_INTERVAL_MS, + visibleGapMs: VISIBLE_CHECK_GAP_MS, + immediate: false, + }); return newRelease; } diff --git a/packages/web/src/hooks/use-poll.ts b/packages/web/src/hooks/use-poll.ts new file mode 100644 index 00000000..d4301cd7 --- /dev/null +++ b/packages/web/src/hooks/use-poll.ts @@ -0,0 +1,52 @@ +import {useEffect} from 'react'; + +// Something asked of the server every so often, and when the page comes back into view, if it +// has not just been asked. + +export type PollOptions = { + enabled: boolean; + intervalMs: number; + // How long after the last ask the page coming back into view asks again. + visibleGapMs: number; + // Whether to ask at once, rather than an interval in. + immediate: boolean; +}; + +/** + * Calls `ask` on that schedule while enabled. Its signal is aborted when the schedule stops, as + * when the component unmounts, so an answer that comes after is dropped. + */ +export function usePoll( + ask: (signal: AbortSignal) => void, + {enabled, intervalMs, visibleGapMs, immediate}: PollOptions, +): void { + useEffect(() => { + if (!enabled) { + return; + } + const stopped = new AbortController(); + let lastAsk = Date.now(); + const run = () => { + lastAsk = Date.now(); + ask(stopped.signal); + }; + const onVisible = () => { + if (document.visibilityState !== 'visible' || Date.now() - lastAsk < visibleGapMs) { + return; + } + run(); + }; + if (immediate) { + run(); + } + const timer = setInterval(run, intervalMs); + document.addEventListener('visibilitychange', onVisible); + window.addEventListener('focus', onVisible); + return () => { + stopped.abort(); + clearInterval(timer); + document.removeEventListener('visibilitychange', onVisible); + window.removeEventListener('focus', onVisible); + }; + }, [ask, enabled, intervalMs, visibleGapMs, immediate]); +} diff --git a/packages/web/src/hooks/use-unread.ts b/packages/web/src/hooks/use-unread.ts index c97c386e..ec25c9d6 100644 --- a/packages/web/src/hooks/use-unread.ts +++ b/packages/web/src/hooks/use-unread.ts @@ -1,5 +1,8 @@ import {createContext, useContext} from 'react'; +/** The conversation the Chat page shows, and the persona's last answer in it, if any. */ +export type Viewing = {personaId: string; answerId: string | null}; + /** * What the agents said that the person hasn't seen, across the organization's conversations, as * the provider around the human side keeps it (components/unread-provider.tsx). @@ -7,23 +10,17 @@ import {createContext, useContext} from 'react'; export type Unread = { // Whether the conversations have been asked about since the page opened. loaded: boolean; - // The personas with something unread, the one open in view left out. + // The personas with something unread, the one the person is looking at left out. unreadIds: ReadonlySet; - // How many conversations have something unread. - count: number; - // Which conversation the Chat page shows while it is in view, which counts as read; null - // elsewhere, and while the page is out of view. - setOpen: (personaId: string | null) => void; - // The person has seen the conversation: unread no longer, at once, ahead of the next poll. - markSeen: (personaId: string) => void; + // 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 = { loaded: false, unreadIds: new Set(), - count: 0, - setOpen: () => undefined, - markSeen: () => undefined, + setViewing: () => undefined, }; export const UnreadContext = createContext(NOTHING); diff --git a/packages/web/src/lib/desktop.ts b/packages/web/src/lib/desktop.ts index 3a875eaa..02f1e45c 100644 --- a/packages/web/src/lib/desktop.ts +++ b/packages/web/src/lib/desktop.ts @@ -28,7 +28,7 @@ export type DesktopBridge = { onUpdateReady(listener: (version: string) => void): () => void; restart(): void; // Optional while installed desktop apps from before notifications still load this page. - notify?(notice: {title: string; body: string; path: string}): void; + notify?(notice: {title: string; body: string; path: string; tag: string}): void; onOpenPath?(listener: (path: string) => void): () => void; setBadge?(count: number): void; }; diff --git a/packages/web/src/lib/notifications.test.ts b/packages/web/src/lib/notifications.test.ts index ef525629..379ac70d 100644 --- a/packages/web/src/lib/notifications.test.ts +++ b/packages/web/src/lib/notifications.test.ts @@ -26,7 +26,13 @@ class FakeNotification { } const open = mock((_path: string) => undefined); -const notice = {title: 'Finance', body: 'Three invoices are due.', path: '/acme/chat/finance'}; +const notice = { + title: 'Finance', + body: 'Three invoices are due.', + path: '/acme/chat/finance', + tag: 'finance', + id: 'answer-1', +}; beforeEach(() => { shown.length = 0; @@ -34,6 +40,7 @@ beforeEach(() => { open.mockClear(); FakeNotification.requestPermission.mockClear(); turnNotificationsOff(); + localStorage.clear(); Object.assign(globalThis, {Notification: FakeNotification}); }); @@ -61,23 +68,36 @@ describe('notifications', () => { test('show nothing while off, and through the browser once on, opening the page when clicked', async () => { permission = 'granted'; - notify(notice, 'finance', open); + notify(notice, open); expect(shown).toEqual([]); await turnNotificationsOn(); - notify(notice, 'finance', open); + notify(notice, open); expect(shown).toEqual([ {title: 'Finance', options: {body: 'Three invoices are due.', tag: 'finance'}}, ]); expect(open).not.toHaveBeenCalled(); }); + test('tell of a message once across the page’s tabs, and of the next one again', async () => { + permission = 'granted'; + await turnNotificationsOn(); + + notify(notice, open); + notify(notice, open); + expect(shown).toHaveLength(1); + + notify({...notice, body: 'And one more.', id: 'answer-2'}, open); + expect(shown).toHaveLength(2); + expect(shown[1]?.options?.body).toBe('And one more.'); + }); + test('go through the desktop app where the page is in it, which needs no leave', async () => { const bridge = { updateReady: () => Promise.resolve(null), onUpdateReady: () => () => undefined, restart: () => undefined, - notify: mock((_notice: typeof notice) => undefined), + notify: mock((_notice: Omit) => undefined), onOpenPath: () => () => undefined, setBadge: () => undefined, }; @@ -86,8 +106,13 @@ describe('notifications', () => { expect(await turnNotificationsOn()).toBe(true); expect(FakeNotification.requestPermission).not.toHaveBeenCalled(); - notify(notice, 'finance', open); - expect(bridge.notify).toHaveBeenCalledWith(notice); + notify(notice, open); + expect(bridge.notify).toHaveBeenCalledWith({ + title: 'Finance', + body: 'Three invoices are due.', + path: '/acme/chat/finance', + tag: 'finance', + }); expect(shown).toEqual([]); }); diff --git a/packages/web/src/lib/notifications.ts b/packages/web/src/lib/notifications.ts index 382b75bf..d4fd8256 100644 --- a/packages/web/src/lib/notifications.ts +++ b/packages/web/src/lib/notifications.ts @@ -5,9 +5,14 @@ import {desktopBridge} from '@/lib/desktop'; // Notification API otherwise. Off until the person turns it on, on each device (Account page). const SETTING_KEY = 'proxy-notifications'; +// What was last told of, by the tag: shared by the page's tabs, so one tells of it and the rest don't. +const TOLD_KEY_PREFIX = 'proxy-notified:'; -/** A message as it is told of: whose, what it said, and the page of the app it is on. */ -export type Notice = {title: string; body: string; path: string}; +/** + * A message as it is told of: whose, what it said, and the page of the app it is on; its tag, one + * per conversation, and its id, the message's. + */ +export type Notice = {title: string; body: string; path: string; tag: string; id: string}; /** How this device can show notifications: through the desktop app, the browser, or not at all. */ export type NotificationSupport = 'desktop' | 'browser' | 'none'; @@ -53,23 +58,30 @@ export function turnNotificationsOff(): void { } /** - * Shows the notice, where notifications are on; `open` is called with its path when it is clicked - * in the browser (the desktop app hands that back through its bridge, `onOpenPath`). One per - * conversation at a time: a newer notice of the same `tag` replaces the one showing. + * Shows the notice, where notifications are on and no other tab of the page has; `open` is called + * with its path when it is clicked in the browser (the desktop app hands that back through its + * bridge, `onOpenPath`). One per conversation at a time: a newer notice of the same `tag` + * replaces the one showing. */ -export function notify(notice: Notice, tag: string, open: (path: string) => void): void { +export function notify(notice: Notice, open: (path: string) => void): void { if (!notificationsOn()) { return; } + const {id, ...shownNotice} = notice; + const toldKey = `${TOLD_KEY_PREFIX}${notice.tag}`; + if (localStorage.getItem(toldKey) === id) { + return; + } + localStorage.setItem(toldKey, id); const bridge = desktopBridge(); if (bridge?.notify) { - bridge.notify(notice); + bridge.notify(shownNotice); return; } if (typeof Notification === 'undefined' || Notification.permission !== 'granted') { return; } - const shown = new Notification(notice.title, {body: notice.body, tag}); + const shown = new Notification(notice.title, {body: notice.body, tag: notice.tag}); shown.onclick = () => { window.focus(); open(notice.path); diff --git a/packages/web/src/pages/chat-page.test.tsx b/packages/web/src/pages/chat-page.test.tsx index 30aea481..8d1c7b7b 100644 --- a/packages/web/src/pages/chat-page.test.tsx +++ b/packages/web/src/pages/chat-page.test.tsx @@ -8,7 +8,7 @@ import {cleanup, fireEvent, render, screen, waitFor, within} from '@testing-libr import {createMemoryRouter, RouterProvider} from 'react-router'; import {HumanSide} from '@/components/human-side'; import {ChatPage} from '@/pages/chat-page'; -import {testHuman} from '@/test-org'; +import {testHuman, testOrg} from '@/test-org'; const realFetch = globalThis.fetch; const fetchMock = mock<(input: string, init?: RequestInit) => Promise>(); @@ -23,6 +23,7 @@ function persona(id: string, name: string, mayChange: boolean): ChatPersona { color: 'blue', chatModel: null, lastMessage: null, + lastAnswer: null, unread: false, }; } @@ -51,7 +52,7 @@ function renderChatPage({ [ { id: 'human', - loader: () => testHuman(), + loader: () => testHuman({...testOrg, experiments: ['harness']}), element: , children: [ { @@ -231,18 +232,19 @@ describe('ChatPage', () => { expect(within(generalRow).queryByRole('status', {name: 'Unread'})).toBeNull(); }); - test('marks the conversation read when an answer shows while the page is in view', async () => { + test('reads the conversation up to the last answer it shows, what the person said after it aside', async () => { renderChatPage({ entries: [ {id: 'e1', kind: 'person', text: 'Hi', isError: false, at: '2026-10-21T10:00:00Z'}, {id: 'e2', kind: 'assistant', text: 'Hello', isError: false, at: '2026-10-21T10:00:01Z'}, + {id: 'e3', kind: 'person', text: 'And?', isError: false, at: '2026-10-21T10:00:02Z'}, ], }); await waitFor(() => { expect(fetchMock).toHaveBeenCalledWith( '/api/orgs/acme/chat/personas/general/read', - expect.objectContaining({method: 'POST'}), + expect.objectContaining({method: 'POST', body: JSON.stringify({entryId: 'e2'})}), ); }); expect( @@ -250,7 +252,7 @@ describe('ChatPage', () => { ).toHaveLength(1); }); - test('marks nothing read while the last word is the person’s', async () => { + test('marks nothing read before the agent has answered', async () => { renderChatPage({ entries: [{id: 'e1', kind: 'person', text: 'Hi', isError: false, at: '2026-10-21T10:00:00Z'}], }); diff --git a/packages/web/src/pages/chat-page.tsx b/packages/web/src/pages/chat-page.tsx index bd99b32f..ad83c147 100644 --- a/packages/web/src/pages/chat-page.tsx +++ b/packages/web/src/pages/chat-page.tsx @@ -13,7 +13,6 @@ import { createPersona, deletePersona, getThread, - markRead, postMessage, updatePersona, } from '@/client/harness-client'; @@ -123,7 +122,7 @@ function subtitle(last: ChatPersona['lastMessage']): string { // to a line as the list has it. function lastOf(log: ThreadLog): ChatPersona['lastMessage'] { const last = log.entries.at(-1); - return last ? {id: last.id, kind: last.kind, text: preview(last.text), at: last.at} : null; + return last ? {kind: last.kind, text: preview(last.text), at: last.at} : null; } // The organization's personas in the second sidebar, each with its head, its name and the last @@ -345,7 +344,6 @@ function Conversation({ const human = useHuman(); const navigate = useNavigate(); const revalidator = useRevalidator(); - const unread = useUnread(); const [log, setLog] = useState(loaded.log); const [text, setText] = useState(''); const [sending, setSending] = useState(false); @@ -378,38 +376,16 @@ function Conversation({ }; }, [readLog, log.busy]); - // The conversation is read as far as the page shows it while the page is in view: each answer - // that shows then is marked read, once, and the provider is told which conversation is open, - // so it is neither counted nor notified of. Out of view, as in a tab behind another, nothing - // is read, and what comes is told of as anywhere else. - const lastEntry = log.entries.at(-1); - const lastAnswerId = lastEntry?.kind === 'assistant' ? lastEntry.id : null; - const marked = useRef(null); + // 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(); useEffect(() => { - const {setOpen, markSeen} = unread; - const read = () => { - if (document.visibilityState !== 'visible') { - setOpen(null); - return; - } - setOpen(personaId); - if (lastAnswerId === null || marked.current === lastAnswerId) { - return; - } - marked.current = lastAnswerId; - void markRead(org, personaId).then((result) => { - if (result.isOk()) { - markSeen(personaId); - } - }); - }; - read(); - document.addEventListener('visibilitychange', read); + setViewing({personaId, answerId: lastAnswerId}); return () => { - document.removeEventListener('visibilitychange', read); - setOpen(null); + setViewing(null); }; - }, [org, personaId, lastAnswerId, unread]); + }, [setViewing, personaId, lastAnswerId]); const send = async (event: FormEvent) => { event.preventDefault(); From 8ae5333fd47d7d7c5a85b9d5c5b23b9340638404 Mon Sep 17 00:00:00 2001 From: hkonsti <45428767+hkonsti@users.noreply.github.com> Date: Thu, 5 Nov 2026 19:51:18 +0000 Subject: [PATCH 3/4] ask what is unread of a read-only answers route, send a failed read again, tell of an answer the page has not shown, and open a notification's page where nothing listens --- packages/api/src/harness/fake-store.ts | 1 + packages/api/src/harness/route.test.ts | 30 +++- packages/api/src/harness/route.ts | 25 ++- packages/api/src/harness/store.ts | 6 +- packages/api/src/server/create-api-router.ts | 1 + packages/contracts/src/harness.ts | 24 ++- packages/db/src/harness.test.ts | 21 ++- packages/db/src/harness.ts | 88 ++++++--- packages/desktop/README.md | 3 +- packages/desktop/src/main.ts | 4 +- packages/desktop/src/preload.ts | 21 ++- packages/web/src/client/harness-client.ts | 5 + .../src/components/unread-provider.test.tsx | 124 ++++++++++--- .../web/src/components/unread-provider.tsx | 170 +++++++++--------- packages/web/src/hooks/use-poll.ts | 12 +- packages/web/src/lib/notifications.test.ts | 22 +++ packages/web/src/lib/notifications.ts | 14 +- packages/web/src/pages/chat-page.test.tsx | 10 +- 18 files changed, 411 insertions(+), 170 deletions(-) diff --git a/packages/api/src/harness/fake-store.ts b/packages/api/src/harness/fake-store.ts index dad0c51e..bacdd57d 100644 --- a/packages/api/src/harness/fake-store.ts +++ b/packages/api/src/harness/fake-store.ts @@ -26,6 +26,7 @@ export function fakeStore(): FakeStore { getAgentTask: mock(), createAgentTask: mock(), listPersonas: mock(), + listAnswers: mock(), ensureFirstPersona: mock(), getPersona: mock(), createPersona: mock(), diff --git a/packages/api/src/harness/route.test.ts b/packages/api/src/harness/route.test.ts index c5ad181d..bd0e25ff 100644 --- a/packages/api/src/harness/route.test.ts +++ b/packages/api/src/harness/route.test.ts @@ -15,7 +15,7 @@ mock.module('./built-in-agent', () => ({ensureBuiltInAgent})); const now = new Date('2026-10-21T10:00:00Z'); const look = {nose: 0.48, noseOut: 0.35, eye: 27, eyeTheta: 55, eyeLat: 8, eyes: 2 as const}; const persona = {id: 'persona-1', name: 'Finance', look, color: 'blue', chatModel: 'claude' as 'claude' | 'glm', createdById: 'user-1'}; -const personaResponse = {id: 'persona-1', name: 'Finance', look, color: 'blue' as const, chatModel: null, mayChange: true, lastMessage: null, lastAnswer: null, unread: false}; +const personaResponse = {id: 'persona-1', name: 'Finance', look, color: 'blue' as const, chatModel: null, mayChange: true, lastMessage: null, unread: false}; const thread: ThreadRow = { id: 'thread-1', orgId: 'org-1', @@ -62,7 +62,7 @@ beforeEach(async () => { deletePersona.mockResolvedValue(Ok(true)); requireViewer.mockResolvedValue(Ok(viewer)); listPersonas.mockResolvedValue( - Ok([{...persona, lastMessage: {kind: 'assistant', text: 'Your vault\n has one entry.', createdAt: now}, lastAnswer: {id: 'e9', text: 'Your vault\n has one entry.'}, unread: false}]), + Ok([{...persona, lastMessage: {kind: 'assistant', text: 'Your vault\n has one entry.', createdAt: now}, unread: false}]), ); getPersona.mockResolvedValue(Ok(persona)); createPersona.mockResolvedValue(Ok({...persona, id: 'persona-2'})); @@ -78,20 +78,21 @@ beforeEach(async () => { describe('the chat routes', () => { test('are refused without the experiment', async () => { - const {handleListPersonasRoute, handlePostMessageRoute} = routes; + const {handleListPersonasRoute, handleListAnswersRoute, handlePostMessageRoute} = routes; requireViewer.mockResolvedValue(Ok({...viewer, experiments: []})); expect((await handleListPersonasRoute(request())).unwrapErr().kind).toBe('forbidden'); + expect((await handleListAnswersRoute(request())).unwrapErr().kind).toBe('forbidden'); expect((await handlePostMessageRoute(request({personaId: 'persona-1'}, {text: 'Hi'}))).unwrapErr().kind).toBe('forbidden'); expect(store.appendPersonMessage).not.toHaveBeenCalled(); }); - test('list the organization’s personas, with their heads, whether the viewer may change each, the last thing said with each and its last answer, on one line, and whether that is unread', async () => { + test('list the organization’s personas, with their heads, whether the viewer may change each, the last thing said with each, on one line, and whether its last answer is unread', async () => { const {handleListPersonasRoute} = routes; store.listPersonas.mockResolvedValue( Ok([ - {...persona, lastMessage: {kind: 'person', text: 'And\n the rest?', createdAt: now}, lastAnswer: {id: 'e9', text: 'Your vault\n has one entry.'}, unread: true}, - {...persona, id: 'persona-3', createdById: 'user-2', lastMessage: null, lastAnswer: null, unread: false}, + {...persona, lastMessage: {kind: 'person', text: 'And\n the rest?', createdAt: now}, unread: true}, + {...persona, id: 'persona-3', createdById: 'user-2', lastMessage: null, unread: false}, { ...persona, id: 'persona-4', @@ -100,7 +101,6 @@ describe('the chat routes', () => { text: '## Found\n\n- **Three** invoices, see [the list](https://x.test)\n\n| Who | Sum |\n| --- | --- |\n| Dana | 40 |\n\n```\ncode\n```', createdAt: now, }, - lastAnswer: {id: 'e10', text: 'Found'}, unread: false, }, ]), @@ -111,18 +111,30 @@ describe('the chat routes', () => { expect(store.listPersonas).toHaveBeenCalledWith('org-1', 'user-1'); expect(listed).toEqual({ personas: [ - {...personaResponse, lastMessage: {kind: 'person', text: 'And the rest?', at: '2026-10-21T10:00:00.000Z'}, lastAnswer: {id: 'e9', text: 'Your vault has one entry.'}, unread: true}, + {...personaResponse, lastMessage: {kind: 'person', text: 'And the rest?', at: '2026-10-21T10:00:00.000Z'}, unread: true}, {...personaResponse, id: 'persona-3', mayChange: false}, { ...personaResponse, id: 'persona-4', lastMessage: {kind: 'assistant', text: 'Found Three invoices, see the list Who Sum Dana 40', at: '2026-10-21T10:00:00.000Z'}, - lastAnswer: {id: 'e10', text: 'Found'}, }, ], }); }); + test('list the persona’s last answer in each of the viewer’s conversations, on one line, and whether it is unread, making nothing', async () => { + const {handleListAnswersRoute} = routes; + store.listAnswers.mockResolvedValue( + Ok([{personaId: 'persona-1', personaName: 'Finance', answer: {id: 'e9', text: 'Your vault\n has one entry.'}, unread: true}]), + ); + + const listed = (await handleListAnswersRoute(request())).unwrap(); + + expect(store.listAnswers).toHaveBeenCalledWith('org-1', 'user-1'); + expect(store.ensureFirstPersona).not.toHaveBeenCalled(); + expect(listed).toEqual({answers: [{personaId: 'persona-1', name: 'Finance', answer: {id: 'e9', text: 'Your vault has one entry.'}, unread: true}]}); + }); + test('an organization without a persona gets a General one, nobody’s, on its first listing', async () => { const {handleListPersonasRoute} = routes; store.listPersonas.mockResolvedValue(Ok([])); diff --git a/packages/api/src/harness/route.ts b/packages/api/src/harness/route.ts index 4e40c7eb..8c376286 100644 --- a/packages/api/src/harness/route.ts +++ b/packages/api/src/harness/route.ts @@ -3,9 +3,10 @@ import type {z} from 'zod'; import {ApiErr, type ApiError, Do, hasExperiment, parseSchema, requirePresent} from '@proxy/utils'; import {preview, threadState, toAvatarColor} from '@proxy/utils/harness'; -import type {EntryRow, LastAnswer, LastMessage, Persona, PersonaRow, ThreadRow} from '@proxy/db/harness'; +import type {EntryRow, LastMessage, Persona, PersonaRow, ThreadRow} from '@proxy/db/harness'; import { type ChatEntry, + type chatAnswersSchema, type ChatPersona, markReadBodySchema, personaBodySchema, @@ -33,6 +34,7 @@ import type {HarnessStore} from './store'; export type ChatStore = Pick< HarnessStore, | 'listPersonas' + | 'listAnswers' | 'ensureFirstPersona' | 'getPersona' | 'createPersona' @@ -52,9 +54,8 @@ function managesPersona(viewer: OrgViewer, persona: PersonaRow): boolean { } // A persona for the response; one made or changed just now has nothing said with it, and nothing unread. -function toPersona(viewer: OrgViewer, persona: PersonaRow & {lastMessage?: LastMessage | null; lastAnswer?: LastAnswer | null; unread?: boolean}): ChatPersona { +function toPersona(viewer: OrgViewer, persona: PersonaRow & {lastMessage?: LastMessage | null; unread?: boolean}): ChatPersona { const last = persona.lastMessage ?? null; - const answer = persona.lastAnswer ?? null; return { id: persona.id, name: persona.name, @@ -63,7 +64,6 @@ function toPersona(viewer: OrgViewer, persona: PersonaRow & {lastMessage?: LastM chatModel: viewer.insider ? persona.chatModel : null, mayChange: managesPersona(viewer, persona), lastMessage: last && {kind: last.kind, text: preview(last.text), at: last.createdAt.toISOString()}, - lastAnswer: answer && {id: answer.id, text: preview(answer.text)}, unread: persona.unread ?? false, }; } @@ -139,6 +139,22 @@ function handleListPersonasRoute(store: ChatStore, request: AuthenticatedRequest }); } +/** `GET /api/orgs/:org/chat/answers`: the persona's last answer in each of the viewer's conversations that has one, and whether they haven't seen it; read only, for every page to ask every so often. */ +function handleListAnswersRoute(store: ChatStore, request: AuthenticatedRequest): Promise, ApiError>> { + return Do(async ($) => { + const viewer = $(await requireChat(request)); + const answers = $(await store.listAnswers(viewer.orgId, viewer.userId)); + return { + answers: answers.map(({personaId, personaName, answer, unread}) => ({ + personaId, + name: personaName, + answer: {id: answer.id, text: preview(answer.text)}, + unread, + })), + }; + }); +} + /** `POST /api/orgs/:org/chat/personas` with `{name, look, color, chatModel?}`: a new persona, every member's, and the organization's built-in login with it where there is none yet. */ function handleCreatePersonaRoute(store: ChatStore, request: AuthenticatedRequest): Promise, ApiError>> { return Do(async ($) => { @@ -222,6 +238,7 @@ function handleMarkReadRoute(store: ChatStore, request: AuthenticatedRequest): P export function chatRoutes(store: ChatStore) { return { handleListPersonasRoute: (request: AuthenticatedRequest) => handleListPersonasRoute(store, request), + handleListAnswersRoute: (request: AuthenticatedRequest) => handleListAnswersRoute(store, request), handleCreatePersonaRoute: (request: AuthenticatedRequest) => handleCreatePersonaRoute(store, request), handleUpdatePersonaRoute: (request: AuthenticatedRequest) => handleUpdatePersonaRoute(store, request), handleDeletePersonaRoute: (request: AuthenticatedRequest) => handleDeletePersonaRoute(store, request), diff --git a/packages/api/src/harness/store.ts b/packages/api/src/harness/store.ts index cf21e140..be2037a8 100644 --- a/packages/api/src/harness/store.ts +++ b/packages/api/src/harness/store.ts @@ -6,7 +6,7 @@ import * as alerts from '@proxy/db/alerts'; import type {AlertRow, NewAlert, PollRow, WatchMatch} from '@proxy/db/alerts'; import {type ConnectionRow, getConnectionWithCredentialById, listConnectionsByIds} from '@proxy/db/connection'; import * as harness from '@proxy/db/harness'; -import type {AfterAnswer, AssistantTurn, EntryRow, FailureNote, JobRow, LastAnswer, LastMessage, Persona, PersonaRow, SubAgentRow, ThreadRow} from '@proxy/db/harness'; +import type {AfterAnswer, AssistantTurn, ConversationAnswer, EntryRow, FailureNote, JobRow, LastMessage, Persona, PersonaRow, SubAgentRow, ThreadRow} from '@proxy/db/harness'; import {type AgentTaskRow, createAgentTask, getAgentTask, type NewAgentTask} from '@proxy/db/task'; import type {SignedInAgent} from '@proxy/db/agent'; @@ -39,7 +39,8 @@ export type HarnessStore = { getAgentTask: (orgId: string, agentId: string, taskId: string) => Promise>; createAgentTask: (task: NewAgentTask) => Promise>; - listPersonas: (orgId: string, userId: string) => Promise, ApiError>>; + listPersonas: (orgId: string, userId: string) => Promise, ApiError>>; + listAnswers: (orgId: string, userId: string) => Promise>; ensureFirstPersona: (orgId: string) => Promise>; getPersona: (orgId: string, personaId: string) => Promise>; createPersona: (orgId: string, createdById: string, persona: Persona) => Promise>; @@ -87,6 +88,7 @@ export const harnessStore: HarnessStore = { createAgentTask, listPersonas: harness.listPersonas, + listAnswers: harness.listAnswers, ensureFirstPersona: harness.ensureFirstPersona, getPersona: harness.getPersona, createPersona: harness.createPersona, diff --git a/packages/api/src/server/create-api-router.ts b/packages/api/src/server/create-api-router.ts index ec524abf..f4d84982 100644 --- a/packages/api/src/server/create-api-router.ts +++ b/packages/api/src/server/create-api-router.ts @@ -200,6 +200,7 @@ export function createApiRouter(): express.Router { // message queues its work. const chat = chatRoutes(harnessStore); org.get('/chat/personas', withAuthResult('List personas route', chat.handleListPersonasRoute)); + org.get('/chat/answers', withAuthResult('List answers route', chat.handleListAnswersRoute)); org.post('/chat/personas', withAuthResult('Create persona route', chat.handleCreatePersonaRoute)); org.put('/chat/personas/:personaId', withAuthResult('Update persona route', chat.handleUpdatePersonaRoute)); org.delete('/chat/personas/:personaId', withAuthResult('Delete persona route', chat.handleDeletePersonaRoute)); diff --git a/packages/contracts/src/harness.ts b/packages/contracts/src/harness.ts index 5ff2f89b..12d726d2 100644 --- a/packages/contracts/src/harness.ts +++ b/packages/contracts/src/harness.ts @@ -49,11 +49,6 @@ export const chatPersonaSchema = z.object({ * before anything was said. */ lastMessage: z.object({kind: speakerSchema, text: z.string(), at: z.string()}).nullable(), - /** - * The persona's last answer in the person's conversation with it, cut to a line, which may come - * before what the person said last; null before it answered. - */ - lastAnswer: z.object({id: z.string(), text: z.string()}).nullable(), /** * Whether the person hasn't seen the last answer: the page hasn't shown it to them since it * came (`POST chat/personas/:personaId/read`). @@ -66,6 +61,25 @@ export type ChatPersona = z.infer; /** `GET chat/personas`: the organization's personas, in the order they were made. */ export const personaListSchema = z.object({personas: z.array(chatPersonaSchema)}); +/** + * The persona's last answer in one of the person's conversations, cut to a line, which may come + * before what the person said last, and whether they haven't seen it. + */ +export const chatAnswerSchema = z.object({ + personaId: z.string(), + name: z.string(), + answer: z.object({id: z.string(), text: z.string()}), + unread: z.boolean(), +}); + +export type ChatAnswer = z.infer; + +/** + * `GET chat/answers`: the last answer in each of the person's conversations that has one, as + * every page asks every so often; it makes nothing, where `GET chat/personas` makes General. + */ +export const chatAnswersSchema = z.object({answers: z.array(chatAnswerSchema)}); + /** The persona made or changed. */ export const personaAnswerSchema = z.object({persona: chatPersonaSchema}); diff --git a/packages/db/src/harness.test.ts b/packages/db/src/harness.test.ts index 27598539..41e8f1ef 100644 --- a/packages/db/src/harness.test.ts +++ b/packages/db/src/harness.test.ts @@ -18,6 +18,7 @@ import { listEntries, listJobEvents, listOpenJobs, + listAnswers, listPersonas, listSubAgents, markThreadRead, @@ -281,6 +282,10 @@ describe.skipIf(!databaseUrl)('the harness, against the database', () => { } return found; }; + const lastAnswer = async () => + unwrap(await listAnswers(orgId, userId)).find( + (candidate) => candidate.personaId === persona.id, + ); const answer = async (workerId: string, text: string) => { const job = unwrap(await claimJob(workerId, 60_000)); if (!job) { @@ -296,11 +301,11 @@ describe.skipIf(!databaseUrl)('the harness, against the database', () => { readyForInference, ), ); - const {lastAnswer} = await listed(); - if (lastAnswer?.text !== text) { - throw new Error(`expected the answer ${text}, got ${JSON.stringify(lastAnswer)}`); + const last = await lastAnswer(); + if (last?.answer.text !== text) { + throw new Error(`expected the answer ${text}, got ${JSON.stringify(last)}`); } - return lastAnswer.id; + return last.answer.id; }; unwrap(await appendPersonMessage(thread.id, 'Anything new?', readyForInference)); @@ -310,7 +315,12 @@ describe.skipIf(!databaseUrl)('the harness, against the database', () => { unwrap(await appendPersonMessage(thread.id, 'And now?', readyForInference)); expect(await listed()).toMatchObject({ lastMessage: {kind: 'person', text: 'And now?'}, - lastAnswer: {id: first, text: 'Nothing new.'}, + unread: true, + }); + expect(await lastAnswer()).toEqual({ + personaId: persona.id, + personaName: persona.name, + answer: {id: first, text: 'Nothing new.'}, unread: true, }); @@ -320,6 +330,7 @@ describe.skipIf(!databaseUrl)('the harness, against the database', () => { expect(unwrap(await markThreadRead(thread.id, second))).toBe(true); expect((await listed()).unread).toBe(false); + expect((await lastAnswer())?.unread).toBe(false); expect(unwrap(await markThreadRead(thread.id, first))).toBe(true); expect((await listed()).unread).toBe(false); diff --git a/packages/db/src/harness.ts b/packages/db/src/harness.ts index 19cdccf8..abab9cbf 100644 --- a/packages/db/src/harness.ts +++ b/packages/db/src/harness.ts @@ -283,22 +283,28 @@ export function toEntry(entry: EntryRecord): EntryRow { }; } +// The persona's last answer in a member's conversation, which may come before what the person said +// last, and how far they have read it. +const lastAnswerSelect = { + personaId: true, + readSeq: true, + entries: { + where: {kind: 'assistant', text: {not: ''}}, + orderBy: {seq: 'desc'}, + take: 1, + select: {id: true, seq: true, text: true}, + }, +} satisfies Prisma.AgentThreadSelect; + /** * The organization's personas, oldest first, with the last thing said in the member's conversation - * with each, the persona's last answer in it, and whether that is unread: said past where the - * member has read to. + * with each, and whether the persona's last answer in it is unread: said past where the member has + * read to. */ export async function listPersonas( orgId: string, userId: string, -): Promise< - Result< - Array< - PersonaRow & {lastMessage: LastMessage | null; lastAnswer: LastAnswer | null; unread: boolean} - >, - ApiError - > -> { +): Promise, ApiError>> { return ( await wrapDb(() => // One snapshot for both, so an answer landing between them can't leave the last thing said @@ -324,49 +330,73 @@ export async function listPersonas( }, orderBy: {createdAt: 'asc'}, }), - // The persona's last answer in each of the member's conversations, which may come - // before what the person said last, and how far they have read it. db.agentThread.findMany({ where: {orgId, userId, parentThreadId: null}, - select: { - personaId: true, - readSeq: true, - entries: { - where: {kind: 'assistant', text: {not: ''}}, - orderBy: {seq: 'desc'}, - take: 1, - select: {id: true, seq: true, text: true}, - }, - }, + select: lastAnswerSelect, }), ], {isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead}, ), ) ).map(([personas, threads]) => { - const answers = new Map( + const unread = new Set( threads.flatMap(({personaId, readSeq, entries: [answer]}) => - answer - ? [[personaId, {id: answer.id, text: answer.text, unread: answer.seq > readSeq}] as const] - : [], + answer && answer.seq > readSeq ? [personaId] : [], ), ); return personas.map(({threads: [thread], ...persona}) => { const last = thread?.entries[0]; - const answer = answers.get(persona.id); return { ...toPersona(persona), lastMessage: last && (last.kind === 'person' || last.kind === 'assistant') ? {kind: last.kind, text: last.text, createdAt: last.createdAt} : null, - lastAnswer: answer ? {id: answer.id, text: answer.text} : null, - unread: answer?.unread ?? false, + unread: unread.has(persona.id), }; }); }); } +/** The persona's last answer in a member's conversation with it, and whether they haven't seen it. */ +export type ConversationAnswer = { + personaId: string; + personaName: string; + answer: LastAnswer; + unread: boolean; +}; + +/** + * The persona's last answer in each of the member's conversations that has one, and whether it is + * unread; one query, and nothing made, for what is asked every so often. + */ +export async function listAnswers( + orgId: string, + userId: string, +): Promise> { + return ( + await wrapDb(() => + db.agentThread.findMany({ + where: {orgId, userId, parentThreadId: null}, + select: {...lastAnswerSelect, persona: {select: {name: true}}}, + }), + ) + ).map((threads) => + threads.flatMap(({personaId, readSeq, persona, entries: [answer]}) => + answer + ? [ + { + personaId, + personaName: persona.name, + answer: {id: answer.id, text: answer.text}, + unread: answer.seq > readSeq, + }, + ] + : [], + ), + ); +} + /** * The organization's first persona, General, made by nobody in particular where it has none: the * organization's row is locked meanwhile, so two first openings of Chat make one. The one there diff --git a/packages/desktop/README.md b/packages/desktop/README.md index 06cd7dc2..8b6d38c4 100644 --- a/packages/desktop/README.md +++ b/packages/desktop/README.md @@ -20,7 +20,8 @@ everything but the app's own origin out of the window: links out of the app open agent's name, what it said, the page of the app it is on and a tag, one per conversation, when an agent answers in Chat while the person is elsewhere and they have turned notifications on; the app shows it as the system's, closing the one of the same tag before it, and, clicked, - focuses the window and hands the page back to the web app, which opens it, or loads the page + focuses the window and hands the page back to the web app, which opens it (the preload goes + there itself where nothing on the page listens, as on the agents' pages), or loads the page where the window has none of the web app's, as on the sign-in and offline pages, or opens a new window on it where there is none. The web app sets the count of conversations with something unread on the app's badge the same way. diff --git a/packages/desktop/src/main.ts b/packages/desktop/src/main.ts index c34595f2..600c850c 100644 --- a/packages/desktop/src/main.ts +++ b/packages/desktop/src/main.ts @@ -48,8 +48,8 @@ function focusWindow(): BrowserWindow { } // Opens a page of the app, as a notification clicked asks: in the window's page of the app, which -// moves there itself, or loaded where the window has none, as on macOS after the window was closed, -// or on the sign-in and offline pages, which can't. +// moves there itself, or goes there where nothing on it listens (preload.ts), or loaded where the +// window has none, as on macOS after the window was closed, or on the splash pages, which can't. function openPath(path: string): void { const open = currentWindow(); const showsApp = open !== null && isAppPage(open.webContents.getURL()); diff --git a/packages/desktop/src/preload.ts b/packages/desktop/src/preload.ts index 030b7511..4918ca5b 100644 --- a/packages/desktop/src/preload.ts +++ b/packages/desktop/src/preload.ts @@ -51,6 +51,20 @@ if (process.platform === 'darwin' && window.location.origin === APP_ORIGIN) { // and the count on the app's badge. The main process answers the app's own pages only // (updates.ts, notifications.ts). +// The page's listeners for a notification clicked. Where none listens, as on the sign-in page or +// the agents' pages, the page goes to the notification's own: a path of the app's, as the main +// process has checked. +const openPathListeners = new Set<(path: string) => void>(); +ipcRenderer.on(OPEN_PATH_CHANNEL, (_event: IpcRendererEvent, path: string) => { + if (openPathListeners.size === 0) { + window.location.assign(path); + return; + } + for (const listener of openPathListeners) { + listener(path); + } +}); + contextBridge.exposeInMainWorld('personalAgentProxyDesktop', { titleBarHidden: process.platform === 'darwin', updateReady: (): Promise => ipcRenderer.invoke(UPDATE_READY_CHANNEL), @@ -63,9 +77,10 @@ contextBridge.exposeInMainWorld('personalAgentProxyDesktop', { notify: (notice: {title: string; body: string; path: string; tag: string}): void => ipcRenderer.send(NOTIFY_CHANNEL, notice), onOpenPath: (listener: (path: string) => void): (() => void) => { - const handler = (_event: IpcRendererEvent, path: string) => listener(path); - ipcRenderer.on(OPEN_PATH_CHANNEL, handler); - return () => ipcRenderer.removeListener(OPEN_PATH_CHANNEL, handler); + openPathListeners.add(listener); + return () => { + openPathListeners.delete(listener); + }; }, setBadge: (count: number): void => ipcRenderer.send(BADGE_CHANNEL, count), }); diff --git a/packages/web/src/client/harness-client.ts b/packages/web/src/client/harness-client.ts index 7556a4e9..b40fc97f 100644 --- a/packages/web/src/client/harness-client.ts +++ b/packages/web/src/client/harness-client.ts @@ -1,4 +1,5 @@ import { + chatAnswersSchema, type markReadBodySchema, personaAnswerSchema, personaListSchema, @@ -18,6 +19,10 @@ export function listPersonas(org: string) { return apiRequest('GET', `${orgApi(org)}/chat/personas`, personaListSchema); } +export function listAnswers(org: string) { + return apiRequest('GET', `${orgApi(org)}/chat/answers`, chatAnswersSchema); +} + export function createPersona(org: string, persona: Persona) { return apiRequest('POST', `${orgApi(org)}/chat/personas`, personaAnswerSchema, persona); } diff --git a/packages/web/src/components/unread-provider.test.tsx b/packages/web/src/components/unread-provider.test.tsx index a41710bd..220f3645 100644 --- a/packages/web/src/components/unread-provider.test.tsx +++ b/packages/web/src/components/unread-provider.test.tsx @@ -1,6 +1,6 @@ /// -import type {ChatPersona} from '@proxy/contracts/harness'; +import type {ChatAnswer} from '@proxy/contracts/harness'; import type {Experiment} from '@proxy/utils'; import {afterEach, beforeEach, describe, expect, mock, test} from 'bun:test'; import {act, cleanup, render, screen, waitFor} from '@testing-library/react'; @@ -12,34 +12,23 @@ import {testHuman, testOrg} from '@/test-org'; const realFetch = globalThis.fetch; const realHasFocus = document.hasFocus.bind(document); 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}; -const LIST = '/api/orgs/acme/chat/personas'; +const LIST = '/api/orgs/acme/chat/answers'; const READ_GENERAL = '/api/orgs/acme/chat/personas/general/read'; -function persona(id: string, unread: boolean): ChatPersona { - return { - id, - name: id, - mayChange: false, - look, - color: 'blue', - chatModel: null, - unread, - lastMessage: {kind: 'person', text: 'Thanks', at: '2026-10-21T10:00:00Z'}, - lastAnswer: {id: `${id}-answer`, text: 'Done.'}, - }; +function persona(id: string, unread: boolean): ChatAnswer { + return {personaId: id, name: id, answer: {id: `${id}-answer`, text: 'Done.'}, unread}; } -// The api: the personas as `listed` has them, and a read that lands; `read` is called with what -// it was sent. -function serve(listed: () => ChatPersona[], read: (body: string) => void = () => undefined) { +// The api: the last answers as `listed` has them, and a read that lands, or fails where `read` +// says so; `read` is called with what it was sent. +function serve(listed: () => ChatAnswer[], read: (body: string) => boolean | void = () => true) { fetchMock.mockImplementation((input, init) => { if (input.endsWith('/read')) { - read(String(init?.body)); - return Promise.resolve(new Response(null, {status: 204})); + const landed = read(String(init?.body)) !== false; + return Promise.resolve(new Response(null, {status: landed ? 204 : 503})); } - return Promise.resolve(Response.json({personas: listed()})); + return Promise.resolve(Response.json({answers: listed()})); }); } @@ -225,6 +214,60 @@ describe('UnreadProvider', () => { expect(readCalls()).toHaveLength(0); }); + test('sends no read before it has asked whether the api has it read already', async () => { + let answer: () => void = () => undefined; + const listed = new Promise((resolve) => { + answer = resolve; + }); + fetchMock.mockImplementation(async (input) => { + if (input.endsWith('/read')) { + return new Response(null, {status: 204}); + } + await listed; + return Response.json({answers: [persona('general', false)]}); + }); + renderProvider(); + const view = await screen.findByRole('button', {name: 'View General'}); + act(() => { + view.click(); + }); + await waitFor(() => { + expect(listCalls()).toHaveLength(1); + }); + expect(readCalls()).toHaveLength(0); + + answer(); + await waitFor(() => { + expect(screen.getByTestId('count').textContent).toBe('0'); + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + expect(readCalls()).toHaveLength(0); + }); + + test('sends a read that failed again once the next poll lands', async () => { + let failing = true; + serve( + () => [persona('general', true)], + () => !failing, + ); + renderProvider(); + await waitFor(() => { + expect(listCalls()).toHaveLength(1); + }); + act(() => { + screen.getByRole('button', {name: 'View General'}).click(); + }); + await waitFor(() => { + expect(readCalls()).toHaveLength(1); + }); + + failing = false; + comeBack(); + await waitFor(() => { + expect(readCalls()).toHaveLength(2); + }); + }); + test('keeps the count of the organization in the sidebar on the person’s account page', async () => { renderProvider(['harness'], '/account'); @@ -240,8 +283,8 @@ describe('UnreadProvider', () => { let answers = {general: 'general-1', finance: 'finance-1'}; let financeRead = false; serve(() => [ - {...persona('general', true), lastAnswer: {id: answers.general, text: 'Filed.'}}, - {...persona('finance', !financeRead), lastAnswer: {id: answers.finance, text: 'Paid.'}}, + {...persona('general', true), answer: {id: answers.general, text: 'Filed.'}}, + {...persona('finance', !financeRead), answer: {id: answers.finance, text: 'Paid.'}}, ]); renderProvider(); await waitFor(() => { @@ -276,6 +319,41 @@ describe('UnreadProvider', () => { expect(notifyDesktop).toHaveBeenCalledTimes(1); }); + test('tells of an answer the page looked at has not shown yet, once the person looks away', async () => { + localStorage.setItem('proxy-notifications', 'on'); + let general = 'general-answer'; + serve( + () => [{...persona('general', true), answer: {id: general, text: 'Filed.'}}], + () => false, + ); + renderProvider(); + await waitFor(() => { + expect(listCalls()).toHaveLength(1); + }); + act(() => { + screen.getByRole('button', {name: 'View General'}).click(); + }); + + // A newer answer than the page shows comes while the person looks: nothing is told yet. + general = 'general-2'; + comeBack(); + await waitFor(() => { + expect(listCalls()).toHaveLength(2); + }); + expect(notifyDesktop).not.toHaveBeenCalled(); + + // The person looks away before the page shows it: it is told of. + act(() => { + screen.getByRole('button', {name: 'Leave'}).click(); + }); + moveOn(6000); + comeBack(); + await waitFor(() => { + expect(notifyDesktop).toHaveBeenCalledTimes(1); + }); + expect(notifyDesktop).toHaveBeenCalledWith(expect.objectContaining({tag: 'general'})); + }); + test('takes the count off the badge when it goes, as when the person signs out', async () => { const {unmount} = renderProvider(); await waitFor(() => { diff --git a/packages/web/src/components/unread-provider.tsx b/packages/web/src/components/unread-provider.tsx index 72468530..92a971bd 100644 --- a/packages/web/src/components/unread-provider.tsx +++ b/packages/web/src/components/unread-provider.tsx @@ -1,7 +1,7 @@ -import type {ChatPersona} from '@proxy/contracts/harness'; +import type {ChatAnswer} from '@proxy/contracts/harness'; import {type ReactNode, useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {useNavigate} from 'react-router'; -import {listPersonas, markRead} from '@/client/harness-client'; +import {listAnswers, markRead} from '@/client/harness-client'; import {useExperiments} from '@/hooks/use-experiment'; import {useHuman} from '@/hooks/use-human'; import {usePoll} from '@/hooks/use-poll'; @@ -31,8 +31,8 @@ function isAttending(): boolean { // An answer first seen unread, at a time, waiting to be told of. type Waiting = {answerId: string; since: number}; -// Whether a poll's personas are the ones kept, so nothing renders anew for them. -function samePersonas(kept: ChatPersona[] | null, listed: ChatPersona[]): boolean { +// Whether a poll's answers are the ones kept, so nothing renders anew for them. +function sameAnswers(kept: ChatAnswer[] | null, listed: ChatAnswer[]): boolean { return kept !== null && JSON.stringify(kept) === JSON.stringify(listed); } @@ -42,7 +42,7 @@ export function UnreadProvider({children}: {children: ReactNode}) { const navigate = useNavigate(); const experiments = useExperiments(); const enabled = org !== '' && experiments.includes('harness'); - const [personas, setPersonas] = useState(null); + const [answers, setAnswers] = useState(null); const [viewing, setViewing] = useState(null); const [attending, setAttending] = useState(isAttending); // The conversation the person is looking at, and as the poll, outside render, reads it. @@ -60,76 +60,88 @@ export function UnreadProvider({children}: {children: ReactNode}) { // Which poll was asked for last: one asked for before it that answers after it is dropped, so a // poll begun before a read can't put back what was unread. const latestPoll = useRef(0); - // Whether the provider is still there: a poll or a read that lands after it is gone is dropped. - const alive = useRef(true); - useEffect(() => { - alive.current = true; - return () => { - alive.current = false; - }; - }, []); + // A read that failed, sent again once a poll lands, by bumping this. + const resendAfterPoll = useRef(false); + const [resends, setResends] = useState(0); - const poll = useCallback(async () => { - if (!alive.current) { - return; - } - latestPoll.current += 1; - const asked = latestPoll.current; - const result = await listPersonas(org); - if (!alive.current || asked !== latestPoll.current || result.isErr()) { - return; - } - const listed = result.value.personas; - setPersonas((kept) => (samePersonas(kept, listed) ? kept : listed)); - const unread = listed.flatMap((persona) => - persona.unread && persona.lastAnswer ? [{persona, answer: persona.lastAnswer}] : [], - ); - const known = told.current; - if (!known) { - told.current = new Map(unread.map(({persona, answer}) => [persona.id, answer.id])); - return; - } - const now = Date.now(); - const nextTold = new Map(); - const nextWaiting = new Map(); - for (const {persona, answer} of unread) { - if (known.get(persona.id) === answer.id || persona.id === lookingRef.current?.personaId) { - nextTold.set(persona.id, answer.id); - continue; + const poll = useCallback( + async (signal: AbortSignal) => { + latestPoll.current += 1; + const asked = latestPoll.current; + const result = await listAnswers(org); + if (signal.aborted || asked !== latestPoll.current || result.isErr()) { + return; } - const before = waiting.current.get(persona.id); - const since = before?.answerId === answer.id ? before.since : now; - if (now - since < CONFIRM_MS) { - nextWaiting.set(persona.id, {answerId: answer.id, since}); - continue; + const listed = result.value.answers; + setAnswers((kept) => (sameAnswers(kept, listed) ? kept : listed)); + if (resendAfterPoll.current) { + resendAfterPoll.current = false; + setResends((count) => count + 1); } - nextTold.set(persona.id, answer.id); - notify( - { - title: persona.name, - body: answer.text, - path: `/${org}/chat/${persona.id}`, - tag: persona.id, - id: answer.id, - }, - (path) => void navigate(path), - ); - } - told.current = nextTold; - waiting.current = nextWaiting; - const since = [...nextWaiting.values()].map((answer) => answer.since); - setConfirmAt(since.length === 0 ? null : Math.min(...since) + CONFIRM_MS); - }, [org, navigate]); + const unread = listed.filter((answer) => answer.unread); + const known = told.current; + if (!known) { + told.current = new Map(unread.map(({personaId, answer}) => [personaId, answer.id])); + return; + } + const now = Date.now(); + const looked = lookingRef.current; + const nextTold = new Map(); + const nextWaiting = new Map(); + for (const {personaId, name, answer} of unread) { + const shown = looked?.personaId === personaId && looked.answerId === answer.id; + if (known.get(personaId) === answer.id || shown) { + nextTold.set(personaId, answer.id); + continue; + } + // Looked at, but the page hasn't shown the answer yet: it is, and read, in a moment. + if (looked?.personaId === personaId) { + nextWaiting.set(personaId, {answerId: answer.id, since: now}); + continue; + } + const before = waiting.current.get(personaId); + const since = before?.answerId === answer.id ? before.since : now; + if (now - since < CONFIRM_MS) { + nextWaiting.set(personaId, {answerId: answer.id, since}); + continue; + } + nextTold.set(personaId, answer.id); + notify( + { + title: name, + body: answer.text, + path: `/${org}/chat/${personaId}`, + tag: personaId, + id: answer.id, + }, + (path) => void navigate(path), + ); + } + told.current = nextTold; + waiting.current = nextWaiting; + const since = [...nextWaiting.values()].map((waited) => waited.since); + setConfirmAt(since.length === 0 ? null : Math.min(...since) + CONFIRM_MS); + }, + [org, navigate], + ); + + const ask = useCallback((signal: AbortSignal) => void poll(signal), [poll]); + const askNow = usePoll(ask, { + enabled, + intervalMs: POLL_MS, + visibleGapMs: VISIBLE_POLL_GAP_MS, + immediate: true, + }); useEffect(() => { if (confirmAt === null) { return; } - const timer = setTimeout(() => void poll(), Math.max(0, confirmAt - Date.now())); + const timer = setTimeout(askNow, Math.max(0, confirmAt - Date.now())); return () => { clearTimeout(timer); }; - }, [confirmAt, poll]); + }, [confirmAt, askNow]); useEffect(() => { const onChange = () => setAttending(isAttending()); @@ -143,20 +155,17 @@ export function UnreadProvider({children}: {children: ReactNode}) { }; }, []); - const ask = useCallback(() => void poll(), [poll]); - usePoll(ask, {enabled, intervalMs: POLL_MS, visibleGapMs: VISIBLE_POLL_GAP_MS, immediate: true}); - - // The answer the person is looking at is read: sent once, unless the api already has it read, - // and asked about again once it lands. One that failed to send is sent again after the next - // poll. + // The answer the person is looking at is read: sent once the first poll has said whether the + // api has it read already, unless it has, and asked about again once it lands. One that failed + // to send is sent again after the next poll. const sent = useRef(null); useEffect(() => { - if (!enabled || !looking?.answerId) { + if (!enabled || !answers || !looking?.answerId) { return; } const {personaId, answerId} = looking; - const known = personas?.find((persona) => persona.id === personaId); - const read = known !== undefined && !known.unread && known.lastAnswer?.id === answerId; + const known = answers.find((answer) => answer.personaId === personaId); + const read = known !== undefined && !known.unread && known.answer.id === answerId; if (read || sent.current === answerId) { return; } @@ -164,11 +173,12 @@ export function UnreadProvider({children}: {children: ReactNode}) { void markRead(org, personaId, answerId).then((result) => { if (result.isErr()) { sent.current = null; + resendAfterPoll.current = true; return; } - void poll(); + askNow(); }); - }, [enabled, org, looking, personas, poll]); + }, [enabled, org, looking, answers, askNow, resends]); // A notification clicked in the desktop app opens its conversation. useEffect(() => desktopBridge()?.onOpenPath?.((path) => void navigate(path)), [navigate]); @@ -177,11 +187,11 @@ export function UnreadProvider({children}: {children: ReactNode}) { const unreadIds = useMemo( () => new Set( - (personas ?? []) - .filter((persona) => persona.unread && persona.id !== lookingAt) - .map((persona) => persona.id), + (answers ?? []) + .filter((answer) => answer.unread && answer.personaId !== lookingAt) + .map((answer) => answer.personaId), ), - [personas, lookingAt], + [answers, lookingAt], ); const count = unreadIds.size; @@ -208,7 +218,7 @@ export function UnreadProvider({children}: {children: ReactNode}) { }; }, [count]); - const loaded = personas !== null; + const loaded = answers !== null; const value = useMemo(() => ({loaded, unreadIds, setViewing}), [loaded, unreadIds]); return {children}; } diff --git a/packages/web/src/hooks/use-poll.ts b/packages/web/src/hooks/use-poll.ts index d4301cd7..b9fcf520 100644 --- a/packages/web/src/hooks/use-poll.ts +++ b/packages/web/src/hooks/use-poll.ts @@ -1,4 +1,4 @@ -import {useEffect} from 'react'; +import {useCallback, useEffect, useRef} from 'react'; // Something asked of the server every so often, and when the page comes back into view, if it // has not just been asked. @@ -14,12 +14,15 @@ export type PollOptions = { /** * Calls `ask` on that schedule while enabled. Its signal is aborted when the schedule stops, as - * when the component unmounts, so an answer that comes after is dropped. + * when the component unmounts, so an answer that comes after is dropped. Returns a function that + * asks at once, off the schedule, with the same signal; it does nothing while the schedule is + * stopped. */ export function usePoll( ask: (signal: AbortSignal) => void, {enabled, intervalMs, visibleGapMs, immediate}: PollOptions, -): void { +): () => void { + const askNow = useRef<() => void>(() => undefined); useEffect(() => { if (!enabled) { return; @@ -36,6 +39,7 @@ export function usePoll( } run(); }; + askNow.current = run; if (immediate) { run(); } @@ -44,9 +48,11 @@ export function usePoll( window.addEventListener('focus', onVisible); return () => { stopped.abort(); + askNow.current = () => undefined; clearInterval(timer); document.removeEventListener('visibilitychange', onVisible); window.removeEventListener('focus', onVisible); }; }, [ask, enabled, intervalMs, visibleGapMs, immediate]); + return useCallback(() => askNow.current(), []); } diff --git a/packages/web/src/lib/notifications.test.ts b/packages/web/src/lib/notifications.test.ts index 379ac70d..393c03ee 100644 --- a/packages/web/src/lib/notifications.test.ts +++ b/packages/web/src/lib/notifications.test.ts @@ -13,6 +13,8 @@ import { // and what was shown. const shown: Array<{title: string; options?: NotificationOptions}> = []; let permission: NotificationPermission = 'default'; +// Whether the page may make one itself: not on Android, whose browser throws. +let constructible = true; class FakeNotification { static get permission() { return permission; @@ -21,6 +23,9 @@ class FakeNotification { onclick: (() => void) | null = null; close = mock(() => undefined); constructor(title: string, options?: NotificationOptions) { + if (!constructible) { + throw new TypeError('Illegal constructor.'); + } shown.push({title, options}); } } @@ -37,6 +42,7 @@ const notice = { beforeEach(() => { shown.length = 0; permission = 'default'; + constructible = true; open.mockClear(); FakeNotification.requestPermission.mockClear(); turnNotificationsOff(); @@ -92,6 +98,22 @@ describe('notifications', () => { expect(shown[1]?.options?.body).toBe('And one more.'); }); + test('tell of a message again that could not be shown, where the leave was taken back or the browser refused to make it', async () => { + permission = 'granted'; + await turnNotificationsOn(); + + permission = 'denied'; + notify(notice, open); + constructible = false; + permission = 'granted'; + notify(notice, open); + expect(shown).toEqual([]); + + constructible = true; + notify(notice, open); + expect(shown).toHaveLength(1); + }); + test('go through the desktop app where the page is in it, which needs no leave', async () => { const bridge = { updateReady: () => Promise.resolve(null), diff --git a/packages/web/src/lib/notifications.ts b/packages/web/src/lib/notifications.ts index d4fd8256..dc47bf84 100644 --- a/packages/web/src/lib/notifications.ts +++ b/packages/web/src/lib/notifications.ts @@ -1,3 +1,4 @@ +import {Result} from 'ts-results-es'; import {desktopBridge} from '@/lib/desktop'; // Word of a message from an agent that came while the person was elsewhere: a notification of @@ -72,16 +73,25 @@ export function notify(notice: Notice, open: (path: string) => void): void { if (localStorage.getItem(toldKey) === id) { return; } - localStorage.setItem(toldKey, id); const bridge = desktopBridge(); if (bridge?.notify) { + localStorage.setItem(toldKey, id); bridge.notify(shownNotice); return; } if (typeof Notification === 'undefined' || Notification.permission !== 'granted') { return; } - const shown = new Notification(notice.title, {body: notice.body, tag: notice.tag}); + // Where the page may not make one itself, as on Android, which shows them through a service + // worker only, nothing is shown. + const made = Result.wrap( + () => new Notification(notice.title, {body: notice.body, tag: notice.tag}), + ); + if (made.isErr()) { + return; + } + localStorage.setItem(toldKey, id); + const shown = made.value; shown.onclick = () => { window.focus(); open(notice.path); diff --git a/packages/web/src/pages/chat-page.test.tsx b/packages/web/src/pages/chat-page.test.tsx index 8d1c7b7b..b7a25bfc 100644 --- a/packages/web/src/pages/chat-page.test.tsx +++ b/packages/web/src/pages/chat-page.test.tsx @@ -23,7 +23,6 @@ function persona(id: string, name: string, mayChange: boolean): ChatPersona { color: 'blue', chatModel: null, lastMessage: null, - lastAnswer: null, unread: false, }; } @@ -34,7 +33,14 @@ const log: ThreadLog = {persona: general, entries: [], busy: false, sleepingUnti beforeEach(() => { fetchMock.mockReset(); - fetchMock.mockImplementation(() => Promise.resolve(new Response(null, {status: 204}))); + // The provider around the page asks for the last answers, none here; the rest lands with nothing. + fetchMock.mockImplementation((input) => + Promise.resolve( + input.endsWith('/chat/answers') + ? Response.json({answers: []}) + : new Response(null, {status: 204}), + ), + ); globalThis.fetch = ((input: string, init?: RequestInit) => fetchMock(input, init)) as typeof fetch; }); From e56823368dd21e95293aa101d011e1c6bc86b756 Mon Sep 17 00:00:00 2001 From: hkonsti <45428767+hkonsti@users.noreply.github.com> Date: Thu, 5 Nov 2026 22:48:58 +0000 Subject: [PATCH 4/4] keep what is unread on the answers route alone, find each conversation's last answer from its newest entry, keep a notification's path on the app's origin, and send no read the api refused again --- packages/api/src/harness/route.test.ts | 31 ++-- packages/api/src/harness/route.ts | 6 +- packages/api/src/harness/store.ts | 2 +- packages/contracts/src/harness.ts | 8 +- .../migration.sql | 0 packages/db/src/harness.test.ts | 23 +-- packages/db/src/harness.ts | 142 ++++++++---------- packages/desktop/src/notifications.ts | 57 +++++-- packages/web/src/components/nav.test.tsx | 1 - .../src/components/unread-provider.test.tsx | 42 +++++- .../web/src/components/unread-provider.tsx | 38 +++-- packages/web/src/hooks/use-unread.ts | 3 - packages/web/src/lib/notifications.test.ts | 23 ++- packages/web/src/lib/notifications.ts | 30 ++-- packages/web/src/pages/chat-page.test.tsx | 27 ++-- packages/web/src/pages/chat-page.tsx | 5 +- 16 files changed, 246 insertions(+), 192 deletions(-) rename packages/db/prisma/migrations/{v045_add_thread_read_cursor => v046_add_thread_read_cursor}/migration.sql (100%) diff --git a/packages/api/src/harness/route.test.ts b/packages/api/src/harness/route.test.ts index bd0e25ff..c33239c7 100644 --- a/packages/api/src/harness/route.test.ts +++ b/packages/api/src/harness/route.test.ts @@ -15,7 +15,7 @@ mock.module('./built-in-agent', () => ({ensureBuiltInAgent})); const now = new Date('2026-10-21T10:00:00Z'); const look = {nose: 0.48, noseOut: 0.35, eye: 27, eyeTheta: 55, eyeLat: 8, eyes: 2 as const}; const persona = {id: 'persona-1', name: 'Finance', look, color: 'blue', chatModel: 'claude' as 'claude' | 'glm', createdById: 'user-1'}; -const personaResponse = {id: 'persona-1', name: 'Finance', look, color: 'blue' as const, chatModel: null, mayChange: true, lastMessage: null, unread: false}; +const personaResponse = {id: 'persona-1', name: 'Finance', look, color: 'blue' as const, chatModel: null, mayChange: true, lastMessage: null}; const thread: ThreadRow = { id: 'thread-1', orgId: 'org-1', @@ -61,9 +61,7 @@ beforeEach(async () => { const {listPersonas, getPersona, createPersona, updatePersona, deletePersona, getMemberThread, ensureMemberThread, listShownEntries, listOpenJobs, appendPersonMessage} = store; deletePersona.mockResolvedValue(Ok(true)); requireViewer.mockResolvedValue(Ok(viewer)); - listPersonas.mockResolvedValue( - Ok([{...persona, lastMessage: {kind: 'assistant', text: 'Your vault\n has one entry.', createdAt: now}, unread: false}]), - ); + listPersonas.mockResolvedValue(Ok([{...persona, lastMessage: {kind: 'assistant', text: 'Your vault\n has one entry.', createdAt: now}}])); getPersona.mockResolvedValue(Ok(persona)); createPersona.mockResolvedValue(Ok({...persona, id: 'persona-2'})); updatePersona.mockResolvedValue(Ok(true)); @@ -87,21 +85,16 @@ describe('the chat routes', () => { expect(store.appendPersonMessage).not.toHaveBeenCalled(); }); - test('list the organization’s personas, with their heads, whether the viewer may change each, the last thing said with each, on one line, and whether its last answer is unread', async () => { + test('list the organization’s personas, with their heads, whether the viewer may change each, and the last thing said with each, on one line', async () => { const {handleListPersonasRoute} = routes; store.listPersonas.mockResolvedValue( Ok([ - {...persona, lastMessage: {kind: 'person', text: 'And\n the rest?', createdAt: now}, unread: true}, - {...persona, id: 'persona-3', createdById: 'user-2', lastMessage: null, unread: false}, + {...persona, lastMessage: {kind: 'assistant', text: 'Your vault\n has one entry.', createdAt: now}}, + {...persona, id: 'persona-3', createdById: 'user-2', lastMessage: null}, { ...persona, id: 'persona-4', - lastMessage: { - kind: 'assistant', - text: '## Found\n\n- **Three** invoices, see [the list](https://x.test)\n\n| Who | Sum |\n| --- | --- |\n| Dana | 40 |\n\n```\ncode\n```', - createdAt: now, - }, - unread: false, + lastMessage: {kind: 'assistant', text: '## Found\n\n- **Three** invoices, see [the list](https://x.test)\n\n| Who | Sum |\n| --- | --- |\n| Dana | 40 |\n\n```\ncode\n```', createdAt: now}, }, ]), ); @@ -111,22 +104,16 @@ describe('the chat routes', () => { expect(store.listPersonas).toHaveBeenCalledWith('org-1', 'user-1'); expect(listed).toEqual({ personas: [ - {...personaResponse, lastMessage: {kind: 'person', text: 'And the rest?', at: '2026-10-21T10:00:00.000Z'}, unread: true}, + {...personaResponse, lastMessage: {kind: 'assistant', text: 'Your vault has one entry.', at: '2026-10-21T10:00:00.000Z'}}, {...personaResponse, id: 'persona-3', mayChange: false}, - { - ...personaResponse, - id: 'persona-4', - lastMessage: {kind: 'assistant', text: 'Found Three invoices, see the list Who Sum Dana 40', at: '2026-10-21T10:00:00.000Z'}, - }, + {...personaResponse, id: 'persona-4', lastMessage: {kind: 'assistant', text: 'Found Three invoices, see the list Who Sum Dana 40', at: '2026-10-21T10:00:00.000Z'}}, ], }); }); test('list the persona’s last answer in each of the viewer’s conversations, on one line, and whether it is unread, making nothing', async () => { const {handleListAnswersRoute} = routes; - store.listAnswers.mockResolvedValue( - Ok([{personaId: 'persona-1', personaName: 'Finance', answer: {id: 'e9', text: 'Your vault\n has one entry.'}, unread: true}]), - ); + store.listAnswers.mockResolvedValue(Ok([{personaId: 'persona-1', personaName: 'Finance', answer: {id: 'e9', text: 'Your vault\n has one entry.'}, unread: true}])); const listed = (await handleListAnswersRoute(request())).unwrap(); diff --git a/packages/api/src/harness/route.ts b/packages/api/src/harness/route.ts index 8c376286..a6eeb9f1 100644 --- a/packages/api/src/harness/route.ts +++ b/packages/api/src/harness/route.ts @@ -53,8 +53,7 @@ function managesPersona(viewer: OrgViewer, persona: PersonaRow): boolean { return manages(viewer, {ownerUserId: null, byId: persona.createdById}); } -// A persona for the response; one made or changed just now has nothing said with it, and nothing unread. -function toPersona(viewer: OrgViewer, persona: PersonaRow & {lastMessage?: LastMessage | null; unread?: boolean}): ChatPersona { +function toPersona(viewer: OrgViewer, persona: PersonaRow & {lastMessage?: LastMessage | null}): ChatPersona { const last = persona.lastMessage ?? null; return { id: persona.id, @@ -64,7 +63,6 @@ function toPersona(viewer: OrgViewer, persona: PersonaRow & {lastMessage?: LastM chatModel: viewer.insider ? persona.chatModel : null, mayChange: managesPersona(viewer, persona), lastMessage: last && {kind: last.kind, text: preview(last.text), at: last.createdAt.toISOString()}, - unread: persona.unread ?? false, }; } @@ -126,7 +124,7 @@ function requireThread(store: ChatStore, viewer: OrgViewer, persona: PersonaRow) }); } -/** `GET /api/orgs/:org/chat/personas`: the organization's personas, in the order they were made, with the viewer's conversations, each with whether the persona said something the viewer hasn't seen; a General one is made where it has none. */ +/** `GET /api/orgs/:org/chat/personas`: the organization's personas, in the order they were made, with the viewer's conversations; a General one is made where it has none. */ function handleListPersonasRoute(store: ChatStore, request: AuthenticatedRequest): Promise, ApiError>> { return Do(async ($) => { const viewer = $(await requireChat(request)); diff --git a/packages/api/src/harness/store.ts b/packages/api/src/harness/store.ts index be2037a8..37104987 100644 --- a/packages/api/src/harness/store.ts +++ b/packages/api/src/harness/store.ts @@ -39,7 +39,7 @@ export type HarnessStore = { getAgentTask: (orgId: string, agentId: string, taskId: string) => Promise>; createAgentTask: (task: NewAgentTask) => Promise>; - listPersonas: (orgId: string, userId: string) => Promise, ApiError>>; + listPersonas: (orgId: string, userId: string) => Promise, ApiError>>; listAnswers: (orgId: string, userId: string) => Promise>; ensureFirstPersona: (orgId: string) => Promise>; getPersona: (orgId: string, personaId: string) => Promise>; diff --git a/packages/contracts/src/harness.ts b/packages/contracts/src/harness.ts index 12d726d2..b4cbf80c 100644 --- a/packages/contracts/src/harness.ts +++ b/packages/contracts/src/harness.ts @@ -49,11 +49,6 @@ export const chatPersonaSchema = z.object({ * before anything was said. */ lastMessage: z.object({kind: speakerSchema, text: z.string(), at: z.string()}).nullable(), - /** - * Whether the person hasn't seen the last answer: the page hasn't shown it to them since it - * came (`POST chat/personas/:personaId/read`). - */ - unread: z.boolean(), }); export type ChatPersona = z.infer; @@ -63,7 +58,8 @@ export const personaListSchema = z.object({personas: z.array(chatPersonaSchema)} /** * The persona's last answer in one of the person's conversations, cut to a line, which may come - * before what the person said last, and whether they haven't seen it. + * before what the person said last, and whether they haven't seen it: the page hasn't shown it to + * them since it came (`POST chat/personas/:personaId/read`). */ export const chatAnswerSchema = z.object({ personaId: z.string(), diff --git a/packages/db/prisma/migrations/v045_add_thread_read_cursor/migration.sql b/packages/db/prisma/migrations/v046_add_thread_read_cursor/migration.sql similarity index 100% rename from packages/db/prisma/migrations/v045_add_thread_read_cursor/migration.sql rename to packages/db/prisma/migrations/v046_add_thread_read_cursor/migration.sql diff --git a/packages/db/src/harness.test.ts b/packages/db/src/harness.test.ts index 41e8f1ef..e54883d6 100644 --- a/packages/db/src/harness.test.ts +++ b/packages/db/src/harness.test.ts @@ -19,7 +19,6 @@ import { listJobEvents, listOpenJobs, listAnswers, - listPersonas, listSubAgents, markThreadRead, requeueJob, @@ -271,17 +270,8 @@ describe.skipIf(!databaseUrl)('the harness, against the database', () => { ]); }); - test('the persona’s answer is unread until the thread is read up to it, never back, and what the person says after it reads nothing', async () => { + test('the persona’s last answer is unread until the thread is read up to it, never back, and what the person says after it reads nothing', async () => { const persona = unwrap(await ensureFirstPersona(orgId)); - const listed = async () => { - const found = unwrap(await listPersonas(orgId, userId)).find( - (candidate) => candidate.id === persona.id, - ); - if (!found) { - throw new Error('the persona is gone'); - } - return found; - }; const lastAnswer = async () => unwrap(await listAnswers(orgId, userId)).find( (candidate) => candidate.personaId === persona.id, @@ -310,13 +300,9 @@ describe.skipIf(!databaseUrl)('the harness, against the database', () => { unwrap(await appendPersonMessage(thread.id, 'Anything new?', readyForInference)); const first = await answer('worker-r', 'Nothing new.'); - expect((await listed()).unread).toBe(true); + expect((await lastAnswer())?.unread).toBe(true); unwrap(await appendPersonMessage(thread.id, 'And now?', readyForInference)); - expect(await listed()).toMatchObject({ - lastMessage: {kind: 'person', text: 'And now?'}, - unread: true, - }); expect(await lastAnswer()).toEqual({ personaId: persona.id, personaName: persona.name, @@ -326,13 +312,12 @@ describe.skipIf(!databaseUrl)('the harness, against the database', () => { const second = await answer('worker-r2', 'Still nothing.'); expect(unwrap(await markThreadRead(thread.id, first))).toBe(true); - expect((await listed()).unread).toBe(true); + expect((await lastAnswer())?.unread).toBe(true); expect(unwrap(await markThreadRead(thread.id, second))).toBe(true); - expect((await listed()).unread).toBe(false); expect((await lastAnswer())?.unread).toBe(false); expect(unwrap(await markThreadRead(thread.id, first))).toBe(true); - expect((await listed()).unread).toBe(false); + expect((await lastAnswer())?.unread).toBe(false); expect(unwrap(await markThreadRead(thread.id, 'no-such-entry'))).toBe(false); expect(unwrap(await markThreadRead('no-such-thread', second))).toBe(false); diff --git a/packages/db/src/harness.ts b/packages/db/src/harness.ts index abab9cbf..9ae331fa 100644 --- a/packages/db/src/harness.ts +++ b/packages/db/src/harness.ts @@ -1,8 +1,8 @@ -import { - type AgentEntryKind, - type AgentJobEventKind, - type AgentJobKind, - type AgentJobStatus, +import type { + AgentEntryKind, + AgentJobEventKind, + AgentJobKind, + AgentJobStatus, Prisma, } from '@prisma/client'; import {Err, Ok, type Result} from 'ts-results-es'; @@ -283,79 +283,45 @@ export function toEntry(entry: EntryRecord): EntryRow { }; } -// The persona's last answer in a member's conversation, which may come before what the person said -// last, and how far they have read it. -const lastAnswerSelect = { - personaId: true, - readSeq: true, - entries: { - where: {kind: 'assistant', text: {not: ''}}, - orderBy: {seq: 'desc'}, - take: 1, - select: {id: true, seq: true, text: true}, - }, -} satisfies Prisma.AgentThreadSelect; - -/** - * The organization's personas, oldest first, with the last thing said in the member's conversation - * with each, and whether the persona's last answer in it is unread: said past where the member has - * read to. - */ +/** The organization's personas, oldest first, with the last thing said in the member's conversation with each. */ export async function listPersonas( orgId: string, userId: string, -): Promise, ApiError>> { +): Promise, ApiError>> { return ( await wrapDb(() => - // One snapshot for both, so an answer landing between them can't leave the last thing said - // newer than the last answer, or the other way round. - db.$transaction( - [ - db.persona.findMany({ - where: {orgId}, + db.persona.findMany({ + where: {orgId}, + select: { + ...personaSelect, + threads: { + where: {userId, parentThreadId: null}, select: { - ...personaSelect, - threads: { - where: {userId, parentThreadId: null}, - select: { - // The agent's turns that only made calls say nothing, so the last with words. - entries: { - where: {kind: {in: ['person', 'assistant']}, text: {not: ''}}, - orderBy: {seq: 'desc'}, - take: 1, - select: {kind: true, text: true, createdAt: true}, - }, - }, + // The agent's turns that only made calls say nothing, so the last with words. + entries: { + where: {kind: {in: ['person', 'assistant']}, text: {not: ''}}, + orderBy: {seq: 'desc'}, + take: 1, + select: {kind: true, text: true, createdAt: true}, }, }, - orderBy: {createdAt: 'asc'}, - }), - db.agentThread.findMany({ - where: {orgId, userId, parentThreadId: null}, - select: lastAnswerSelect, - }), - ], - {isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead}, - ), + }, + }, + orderBy: {createdAt: 'asc'}, + }), ) - ).map(([personas, threads]) => { - const unread = new Set( - threads.flatMap(({personaId, readSeq, entries: [answer]}) => - answer && answer.seq > readSeq ? [personaId] : [], - ), - ); - return personas.map(({threads: [thread], ...persona}) => { - const last = thread?.entries[0]; + ).map((personas) => + personas.map(({threads, ...persona}) => { + const last = threads[0]?.entries[0]; return { ...toPersona(persona), lastMessage: last && (last.kind === 'person' || last.kind === 'assistant') ? {kind: last.kind, text: last.text, createdAt: last.createdAt} : null, - unread: unread.has(persona.id), }; - }); - }); + }), + ); } /** The persona's last answer in a member's conversation with it, and whether they haven't seen it. */ @@ -368,32 +334,46 @@ export type ConversationAnswer = { /** * The persona's last answer in each of the member's conversations that has one, and whether it is - * unread; one query, and nothing made, for what is asked every so often. + * unread; one query, and nothing made, for what is asked every so often. Each conversation's + * answer is found on its own, from the newest entry back by the `(threadId, seq)` index, so a long + * conversation is not read whole for its last answer. */ export async function listAnswers( orgId: string, userId: string, ): Promise> { return ( - await wrapDb(() => - db.agentThread.findMany({ - where: {orgId, userId, parentThreadId: null}, - select: {...lastAnswerSelect, persona: {select: {name: true}}}, - }), + await wrapDb( + () => db.$queryRaw< + Array<{ + personaId: string; + personaName: string; + id: string; + text: string; + unread: boolean; + }> + >` + SELECT t."personaId", p."name" AS "personaName", e."id", e."text", + e."seq" > t."readSeq" AS "unread" + FROM "AgentThread" AS t + JOIN "Persona" AS p ON p."id" = t."personaId" + CROSS JOIN LATERAL ( + SELECT "id", "seq", "text" + FROM "AgentEntry" + WHERE "threadId" = t."id" AND "kind" = 'assistant' AND "text" <> '' + ORDER BY "seq" DESC + LIMIT 1 + ) AS e + WHERE t."orgId" = ${orgId} AND t."userId" = ${userId} AND t."parentThreadId" IS NULL + `, ) - ).map((threads) => - threads.flatMap(({personaId, readSeq, persona, entries: [answer]}) => - answer - ? [ - { - personaId, - personaName: persona.name, - answer: {id: answer.id, text: answer.text}, - unread: answer.seq > readSeq, - }, - ] - : [], - ), + ).map((rows) => + rows.map(({personaId, personaName, id, text, unread}) => ({ + personaId, + personaName, + answer: {id, text}, + unread, + })), ); } diff --git a/packages/desktop/src/notifications.ts b/packages/desktop/src/notifications.ts index cf9846be..01c279b9 100644 --- a/packages/desktop/src/notifications.ts +++ b/packages/desktop/src/notifications.ts @@ -2,26 +2,68 @@ import {app, ipcMain, Notification} from 'electron'; import {z} from 'zod'; import {BADGE_CHANNEL, NOTIFY_CHANNEL} from './channels'; +import {APP_ORIGIN} from './env'; import {fromApp} from './ipc'; // Notifications, as the page asks for them: what an agent said while the person was elsewhere, // shown by the system, and the count of conversations with something unread on the app's badge. // A notification clicked opens its conversation, the page it names, in the window. -// A page of the app, by its path: one slash at the start, so it can't name another host. The tag, -// one per conversation, is optional while pages from before it still ask. +// A page of the app, by its path: what it names, read as the page reads it, on the app's own +// origin, and passed on as read, so nothing the URL parser forgives, such as a backslash for a +// slash (`/\\host`) or a tab between two slashes, can name another host. The tag, one per +// conversation, is optional while pages from before it still ask. +const appPathSchema = z + .string() + .startsWith('/') + .transform((raw, ctx) => { + const url = URL.canParse(raw, APP_ORIGIN) ? new URL(raw, APP_ORIGIN) : null; + if (url?.origin !== APP_ORIGIN) { + ctx.addIssue({code: 'custom', message: 'Not a page of the app'}); + return z.NEVER; + } + return `${url.pathname}${url.search}${url.hash}`; + }); + const noticeSchema = z.object({ title: z.string(), body: z.string(), - path: z.string().regex(/^\/(?!\/)/), + path: appPathSchema, tag: z.string().optional(), }); // The notifications on screen, held until they close: one only the system holds is collected, -// and its click with it. One per tag: a newer notice of the same tag closes the one showing. +// and its click with it. One per tag: a newer notice of the same tag closes the one showing, and +// is let go of then, as macOS may never say it closed. The system doesn't always say one was +// dismissed either, so only the latest are held: an older one may stay in the Notification +// Center, but its click opens no conversation. +const MAX_SHOWING = 20; const showing = new Set(); const tagged = new Map(); +function hold(shown: Notification, tag: string | undefined): void { + showing.add(shown); + if (tag !== undefined) { + const before = tagged.get(tag); + tagged.set(tag, shown); + if (before !== undefined) { + showing.delete(before); + before.close(); + } + } + // A Set keeps the order things were added in: the first is the oldest. + const oldest = showing.size > MAX_SHOWING ? showing.values().next().value : undefined; + if (oldest === undefined) { + return; + } + showing.delete(oldest); + for (const [key, held] of tagged) { + if (held === oldest) { + tagged.delete(key); + } + } +} + function forget(shown: Notification, tag: string | undefined): void { showing.delete(shown); if (tag !== undefined && tagged.get(tag) === shown) { @@ -37,17 +79,12 @@ export function initNotifications(openPath: (path: string) => void): void { } const {title, body, path, tag} = notice.data; const shown = new Notification({title, body}); - showing.add(shown); shown.on('close', () => forget(shown, tag)); shown.on('click', () => { forget(shown, tag); openPath(path); }); - if (tag !== undefined) { - const before = tagged.get(tag); - tagged.set(tag, shown); - before?.close(); - } + hold(shown, tag); shown.show(); }); diff --git a/packages/web/src/components/nav.test.tsx b/packages/web/src/components/nav.test.tsx index 61c30502..6d417a12 100644 --- a/packages/web/src/components/nav.test.tsx +++ b/packages/web/src/components/nav.test.tsx @@ -18,7 +18,6 @@ afterEach(() => { // What is unread, as the provider around the human side would have it. function unreadOf(ids: string[]): Unread { return { - loaded: true, unreadIds: new Set(ids), setViewing: () => undefined, }; diff --git a/packages/web/src/components/unread-provider.test.tsx b/packages/web/src/components/unread-provider.test.tsx index 220f3645..44884b15 100644 --- a/packages/web/src/components/unread-provider.test.tsx +++ b/packages/web/src/components/unread-provider.test.tsx @@ -21,12 +21,16 @@ function persona(id: string, unread: boolean): ChatAnswer { } // The api: the last answers as `listed` has them, and a read that lands, or fails where `read` -// says so; `read` is called with what it was sent. -function serve(listed: () => ChatAnswer[], read: (body: string) => boolean | void = () => true) { +// says so, with its status, or 503; `read` is called with what it was sent. +function serve( + listed: () => ChatAnswer[], + read: (body: string) => boolean | number | void = () => true, +) { fetchMock.mockImplementation((input, init) => { if (input.endsWith('/read')) { - const landed = read(String(init?.body)) !== false; - return Promise.resolve(new Response(null, {status: landed ? 204 : 503})); + const landed = read(String(init?.body)); + const status = typeof landed === 'number' ? landed : landed === false ? 503 : 204; + return Promise.resolve(new Response(null, {status})); } return Promise.resolve(Response.json({answers: listed()})); }); @@ -168,7 +172,8 @@ describe('UnreadProvider', () => { expect(screen.getByTestId('ids').textContent).toBe('finance'); }); expect(document.title).toBe('(1) Personal Agent Proxy'); - expect(setBadge).toHaveBeenLastCalledWith(1); + // The badge goes from one count to the next, never to none between. + expect(setBadge.mock.calls.map(([count]) => count)).toEqual([0, 2, 1]); expect(readCalls()).toHaveLength(1); }); @@ -268,6 +273,33 @@ describe('UnreadProvider', () => { }); }); + test('sends no read the api refused again, as of an answer gone', async () => { + serve( + () => [persona('general', true)], + () => 404, + ); + renderProvider(); + await waitFor(() => { + expect(listCalls()).toHaveLength(1); + }); + act(() => { + screen.getByRole('button', {name: 'View General'}).click(); + }); + await waitFor(() => { + expect(readCalls()).toHaveLength(1); + }); + + comeBack(); + await waitFor(() => { + expect(listCalls()).toHaveLength(2); + }); + comeBack(); + await waitFor(() => { + expect(listCalls()).toHaveLength(3); + }); + expect(readCalls()).toHaveLength(1); + }); + test('keeps the count of the organization in the sidebar on the person’s account page', async () => { renderProvider(['harness'], '/account'); diff --git a/packages/web/src/components/unread-provider.tsx b/packages/web/src/components/unread-provider.tsx index 92a971bd..fda2b1e1 100644 --- a/packages/web/src/components/unread-provider.tsx +++ b/packages/web/src/components/unread-provider.tsx @@ -1,4 +1,5 @@ import type {ChatAnswer} from '@proxy/contracts/harness'; +import type {FetchError} from '@proxy/utils'; import {type ReactNode, useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {useNavigate} from 'react-router'; import {listAnswers, markRead} from '@/client/harness-client'; @@ -21,8 +22,19 @@ import {notify} from '@/lib/notifications'; const POLL_MS = 20 * 1000; const VISIBLE_POLL_GAP_MS = 5 * 1000; +// How long an answer stays unread before it is told of: past what the Chat page takes to show it +// and send it as read, in the window the person reads it in, as it reads the conversation again +// every 4 seconds (pages/chat-page.tsx). const CONFIRM_MS = 5 * 1000; +// Whether a read that failed may land sent again: not one the api refused, as for an answer or a +// persona gone, which never will. +function mayLandLater(error: FetchError): boolean { + return ( + error.kind !== 'http' || error.status >= 500 || error.status === 408 || error.status === 429 + ); +} + // Whether the person is looking at the page: in view, and in the window they are in. function isAttending(): boolean { return document.visibilityState === 'visible' && document.hasFocus(); @@ -157,7 +169,7 @@ export function UnreadProvider({children}: {children: ReactNode}) { // The answer the person is looking at is read: sent once the first poll has said whether the // api has it read already, unless it has, and asked about again once it lands. One that failed - // to send is sent again after the next poll. + // to send is sent again after the next poll, unless the api refused it. const sent = useRef(null); useEffect(() => { if (!enabled || !answers || !looking?.answerId) { @@ -172,8 +184,10 @@ export function UnreadProvider({children}: {children: ReactNode}) { sent.current = answerId; void markRead(org, personaId, answerId).then((result) => { if (result.isErr()) { - sent.current = null; - resendAfterPoll.current = true; + if (mayLandLater(result.error)) { + sent.current = null; + resendAfterPoll.current = true; + } return; } askNow(); @@ -208,17 +222,15 @@ export function UnreadProvider({children}: {children: ReactNode}) { }; }, [count]); useEffect(() => { - const bridge = desktopBridge(); - bridge?.setBadge?.(count); - if (count === 0) { - return; - } - return () => { - bridge?.setBadge?.(0); - }; + desktopBridge()?.setBadge?.(count); }, [count]); + useEffect( + () => () => { + desktopBridge()?.setBadge?.(0); + }, + [], + ); - const loaded = answers !== null; - const value = useMemo(() => ({loaded, unreadIds, setViewing}), [loaded, unreadIds]); + const value = useMemo(() => ({unreadIds, setViewing}), [unreadIds]); return {children}; } diff --git a/packages/web/src/hooks/use-unread.ts b/packages/web/src/hooks/use-unread.ts index ec25c9d6..e326fc64 100644 --- a/packages/web/src/hooks/use-unread.ts +++ b/packages/web/src/hooks/use-unread.ts @@ -8,8 +8,6 @@ export type Viewing = {personaId: string; answerId: string | null}; * the provider around the human side keeps it (components/unread-provider.tsx). */ export type Unread = { - // Whether the conversations have been asked about since the page opened. - loaded: boolean; // The personas with something unread, the one the person is looking at left out. unreadIds: ReadonlySet; // Which conversation the Chat page shows, and up to which answer; null elsewhere. What it shows @@ -18,7 +16,6 @@ export type Unread = { }; const NOTHING: Unread = { - loaded: false, unreadIds: new Set(), setViewing: () => undefined, }; diff --git a/packages/web/src/lib/notifications.test.ts b/packages/web/src/lib/notifications.test.ts index 393c03ee..ab482e9a 100644 --- a/packages/web/src/lib/notifications.test.ts +++ b/packages/web/src/lib/notifications.test.ts @@ -10,8 +10,9 @@ import { } from '@/lib/notifications'; // A browser's Notification, as far as the page uses it: the leave it has, the one it asks for, -// and what was shown. -const shown: Array<{title: string; options?: NotificationOptions}> = []; +// and what was shown, `renotify` with it, which TypeScript's DOM types leave out. +type ShownOptions = NotificationOptions & {renotify?: boolean}; +const shown: Array<{title: string; options?: ShownOptions}> = []; let permission: NotificationPermission = 'default'; // Whether the page may make one itself: not on Android, whose browser throws. let constructible = true; @@ -22,7 +23,7 @@ class FakeNotification { static requestPermission = mock(() => Promise.resolve(permission)); onclick: (() => void) | null = null; close = mock(() => undefined); - constructor(title: string, options?: NotificationOptions) { + constructor(title: string, options?: ShownOptions) { if (!constructible) { throw new TypeError('Illegal constructor.'); } @@ -72,6 +73,17 @@ describe('notifications', () => { expect(notificationsOn()).toBe(false); }); + test('are off where the browser has taken its leave back since, so turning them on asks again', async () => { + permission = 'granted'; + await turnNotificationsOn(); + expect(notificationsOn()).toBe(true); + + permission = 'default'; + expect(notificationsOn()).toBe(false); + notify(notice, open); + expect(shown).toEqual([]); + }); + test('show nothing while off, and through the browser once on, opening the page when clicked', async () => { permission = 'granted'; notify(notice, open); @@ -80,7 +92,10 @@ describe('notifications', () => { await turnNotificationsOn(); notify(notice, open); expect(shown).toEqual([ - {title: 'Finance', options: {body: 'Three invoices are due.', tag: 'finance'}}, + { + title: 'Finance', + options: {body: 'Three invoices are due.', tag: 'finance', renotify: true}, + }, ]); expect(open).not.toHaveBeenCalled(); }); diff --git a/packages/web/src/lib/notifications.ts b/packages/web/src/lib/notifications.ts index dc47bf84..44b8f4df 100644 --- a/packages/web/src/lib/notifications.ts +++ b/packages/web/src/lib/notifications.ts @@ -28,9 +28,19 @@ export function notificationSupport(): NotificationSupport { return 'none'; } -/** Whether the person turned notifications on, on this device. */ +/** + * Whether the person turned notifications on, on this device, and they can be shown: in the + * browser, while it still lets the page, which may have been taken back in its settings since. + */ export function notificationsOn(): boolean { - return localStorage.getItem(SETTING_KEY) === 'on'; + if (localStorage.getItem(SETTING_KEY) !== 'on') { + return false; + } + const support = notificationSupport(); + if (support === 'browser') { + return Notification.permission === 'granted'; + } + return support === 'desktop'; } /** Whether the browser lets the page notify; a desktop app needs no leave, and takes the system's. */ @@ -62,7 +72,7 @@ export function turnNotificationsOff(): void { * Shows the notice, where notifications are on and no other tab of the page has; `open` is called * with its path when it is clicked in the browser (the desktop app hands that back through its * bridge, `onOpenPath`). One per conversation at a time: a newer notice of the same `tag` - * replaces the one showing. + * replaces the one showing, and alerts again. */ export function notify(notice: Notice, open: (path: string) => void): void { if (!notificationsOn()) { @@ -79,14 +89,16 @@ export function notify(notice: Notice, open: (path: string) => void): void { bridge.notify(shownNotice); return; } - if (typeof Notification === 'undefined' || Notification.permission !== 'granted') { - return; - } + // A newer notice of the conversation alerts again as it replaces the one showing, as the + // desktop app's does; `renotify` is the browsers', missing from TypeScript's DOM types. + const options: NotificationOptions & {renotify: boolean} = { + body: notice.body, + tag: notice.tag, + renotify: true, + }; // Where the page may not make one itself, as on Android, which shows them through a service // worker only, nothing is shown. - const made = Result.wrap( - () => new Notification(notice.title, {body: notice.body, tag: notice.tag}), - ); + const made = Result.wrap(() => new Notification(notice.title, options)); if (made.isErr()) { return; } diff --git a/packages/web/src/pages/chat-page.test.tsx b/packages/web/src/pages/chat-page.test.tsx index b7a25bfc..c03a7d58 100644 --- a/packages/web/src/pages/chat-page.test.tsx +++ b/packages/web/src/pages/chat-page.test.tsx @@ -1,6 +1,6 @@ /// -import type {ChatPersona, ThreadLog} from '@proxy/contracts/harness'; +import type {ChatAnswer, 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'; @@ -23,21 +23,23 @@ function persona(id: string, name: string, mayChange: boolean): ChatPersona { color: 'blue', chatModel: null, lastMessage: null, - unread: false, }; } const general = persona('general', 'General', false); const finance = persona('finance', 'Finance', true); const log: ThreadLog = {persona: general, entries: [], busy: false, sleepingUntil: null}; +// The last answers the provider around the page is given: none, unless a test says otherwise. +let answers: ChatAnswer[] = []; beforeEach(() => { fetchMock.mockReset(); - // The provider around the page asks for the last answers, none here; the rest lands with nothing. + answers = []; + // The provider around the page asks for the last answers; the rest lands with nothing. fetchMock.mockImplementation((input) => Promise.resolve( input.endsWith('/chat/answers') - ? Response.json({answers: []}) + ? Response.json({answers}) : new Response(null, {status: 204}), ), ); @@ -225,15 +227,18 @@ describe('ChatPage', () => { }); test('marks the agents with something unread in the list, never the open one', async () => { - renderChatPage({ - personas: [ - {...general, unread: true}, - {...finance, unread: true}, - ], - }); + answers = [general, finance].map(({id, name}) => ({ + personaId: id, + name, + answer: {id: `${id}-answer`, text: 'Done.'}, + unread: true, + })); + renderChatPage(); const financeRow = await screen.findByRole('link', {name: /Finance/}); - expect(within(financeRow).getByRole('status', {name: 'Unread'})).toBeDefined(); + await waitFor(() => { + expect(within(financeRow).getByRole('status', {name: 'Unread'})).toBeDefined(); + }); const generalRow = screen.getByRole('link', {name: /General/}); expect(within(generalRow).queryByRole('status', {name: 'Unread'})).toBeNull(); }); diff --git a/packages/web/src/pages/chat-page.tsx b/packages/web/src/pages/chat-page.tsx index ad83c147..efdb7c7d 100644 --- a/packages/web/src/pages/chat-page.tsx +++ b/packages/web/src/pages/chat-page.tsx @@ -157,10 +157,9 @@ function PersonaList({ const org = useOrgSlug(); const unread = useUnread(); const [hovered, setHovered] = useState(takeOpeningPersona); - // Whether the persona said something the person hasn't seen: as the provider has it, which - // follows along, or as the page was loaded, until the provider has asked. + // Whether the persona said something the person hasn't seen, as the provider has it. const isUnread = (persona: ChatPersona) => - persona.id !== current && (unread.loaded ? unread.unreadIds.has(persona.id) : persona.unread); + persona.id !== current && unread.unreadIds.has(persona.id); return (