diff --git a/packages/api/src/harness/fake-store.ts b/packages/api/src/harness/fake-store.ts index 697a14a0..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(), @@ -36,6 +37,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..c33239c7 100644 --- a/packages/api/src/harness/route.test.ts +++ b/packages/api/src/harness/route.test.ts @@ -76,10 +76,11 @@ 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(); }); @@ -110,6 +111,17 @@ describe('the chat routes', () => { }); }); + 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([])); @@ -234,4 +246,26 @@ 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 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'}, {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'}, {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 e5467958..a6eeb9f1 100644 --- a/packages/api/src/harness/route.ts +++ b/packages/api/src/harness/route.ts @@ -6,7 +6,9 @@ import {preview, threadState, toAvatarColor} from '@proxy/utils/harness'; import type {EntryRow, LastMessage, Persona, PersonaRow, ThreadRow} from '@proxy/db/harness'; import { type ChatEntry, + type chatAnswersSchema, type ChatPersona, + markReadBodySchema, personaBodySchema, type personaAnswerSchema, type personaListSchema, @@ -32,6 +34,7 @@ import type {HarnessStore} from './store'; export type ChatStore = Pick< HarnessStore, | 'listPersonas' + | 'listAnswers' | 'ensureFirstPersona' | 'getPersona' | 'createPersona' @@ -42,6 +45,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). @@ -133,6 +137,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 ($) => { @@ -198,14 +218,30 @@ function handlePostMessageRoute(store: ChatStore, request: AuthenticatedRequest) }); } +/** `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)); + // 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))); + } + }); +} + /** The Chat page's routes, on the store. */ 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), 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..37104987 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, 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'; @@ -40,6 +40,7 @@ export type HarnessStore = { createAgentTask: (task: NewAgentTask) => Promise>; 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>; @@ -50,6 +51,7 @@ export type HarnessStore = { listShownEntries: (threadId: string) => Promise>; listOpenJobs: (threadId: string) => Promise>; appendPersonMessage: (threadId: string, text: 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>; @@ -86,6 +88,7 @@ export const harnessStore: HarnessStore = { createAgentTask, listPersonas: harness.listPersonas, + listAnswers: harness.listAnswers, ensureFirstPersona: harness.ensureFirstPersona, getPersona: harness.getPersona, createPersona: harness.createPersona, @@ -96,6 +99,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 ed889dcc..93efdfd3 100644 --- a/packages/api/src/server/create-api-router.ts +++ b/packages/api/src/server/create-api-router.ts @@ -214,11 +214,13 @@ 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)); 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..b4cbf80c 100644 --- a/packages/contracts/src/harness.ts +++ b/packages/contracts/src/harness.ts @@ -56,6 +56,26 @@ 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: the page hasn't shown it to + * them since it came (`POST chat/personas/:personaId/read`). + */ +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}); @@ -94,5 +114,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/migrations/v046_add_thread_read_cursor/migration.sql b/packages/db/prisma/migrations/v046_add_thread_read_cursor/migration.sql new file mode 100644 index 00000000..3f2e0dc3 --- /dev/null +++ b/packages/db/prisma/migrations/v046_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 e529ac85..d7da8ee5 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -665,6 +665,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 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()) updatedAt DateTime @default(now()) @updatedAt diff --git a/packages/db/src/harness.test.ts b/packages/db/src/harness.test.ts index 2e9b8890..e54883d6 100644 --- a/packages/db/src/harness.test.ts +++ b/packages/db/src/harness.test.ts @@ -18,7 +18,9 @@ import { listEntries, listJobEvents, listOpenJobs, + listAnswers, listSubAgents, + markThreadRead, requeueJob, type JobRow, type NewEntry, @@ -268,6 +270,59 @@ describe.skipIf(!databaseUrl)('the harness, against the database', () => { ]); }); + 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 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) { + throw new Error('nothing to claim'); + } + unwrap( + await completeInference( + job.id, + workerId, + thread.id, + subTurn(text), + {plans: {}}, + readyForInference, + ), + ); + const last = await lastAnswer(); + if (last?.answer.text !== text) { + throw new Error(`expected the answer ${text}, got ${JSON.stringify(last)}`); + } + return last.answer.id; + }; + + unwrap(await appendPersonMessage(thread.id, 'Anything new?', readyForInference)); + const first = await answer('worker-r', 'Nothing new.'); + expect((await lastAnswer())?.unread).toBe(true); + + unwrap(await appendPersonMessage(thread.id, 'And now?', readyForInference)); + expect(await lastAnswer()).toEqual({ + personaId: persona.id, + personaName: persona.name, + answer: {id: first, text: 'Nothing new.'}, + unread: true, + }); + + const second = await answer('worker-r2', 'Still nothing.'); + expect(unwrap(await markThreadRead(thread.id, first))).toBe(true); + expect((await lastAnswer())?.unread).toBe(true); + + expect(unwrap(await markThreadRead(thread.id, second))).toBe(true); + expect((await lastAnswer())?.unread).toBe(false); + expect(unwrap(await markThreadRead(thread.id, first))).toBe(true); + 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); + }); + 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..9ae331fa 100644 --- a/packages/db/src/harness.ts +++ b/packages/db/src/harness.ts @@ -324,6 +324,59 @@ export async function listPersonas( ); } +/** 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. 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.$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((rows) => + rows.map(({personaId, personaName, id, text, unread}) => ({ + personaId, + personaName, + answer: {id, text}, + unread, + })), + ); +} + /** * 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 @@ -477,6 +530,25 @@ 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}; +/** 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 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, + entryId: string, +): Promise> { + return ( + await wrapDb( + () => + 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); +} + /** * The thread's agent as it acts in the thread: signed in as the member whose conversation it is, * so it reaches what they see, their own connections and vault beside the shared ones. `Ok(null)` diff --git a/packages/desktop/README.md b/packages/desktop/README.md index d984febc..8b6d38c4 100644 --- a/packages/desktop/README.md +++ b/packages/desktop/README.md @@ -16,6 +16,15 @@ 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, 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 (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. - 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..600c850c 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'; +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, 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,20 @@ function focusWindow(): BrowserWindow { return win; } +// Opens a page of the app, as a notification clicked asks: in the window's page of the app, which +// 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()); + const win = focusWindow(); + if (!showsApp) { + loadApp(win, path); + return; + } + win.webContents.send(OPEN_PATH_CHANNEL, path); +} + async function onDeepLink(link: DeepLink): Promise { await app.whenReady(); const win = focusWindow(); @@ -117,6 +134,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..01c279b9 --- /dev/null +++ b/packages/desktop/src/notifications.ts @@ -0,0 +1,98 @@ +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: 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: 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 +// 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) { + tagged.delete(tag); + } +} + +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 {title, body, path, tag} = notice.data; + const shown = new Notification({title, body}); + shown.on('close', () => forget(shown, tag)); + shown.on('click', () => { + forget(shown, tag); + openPath(path); + }); + hold(shown, tag); + 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..4918ca5b 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,23 @@ 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). + +// 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', @@ -52,4 +74,13 @@ contextBridge.exposeInMainWorld('personalAgentProxyDesktop', { return () => ipcRenderer.removeListener(UPDATE_READY_CHANNEL, handler); }, restart: (): void => ipcRenderer.send(RESTART_CHANNEL), + notify: (notice: {title: string; body: string; path: string; tag: string}): void => + ipcRenderer.send(NOTIFY_CHANNEL, notice), + onOpenPath: (listener: (path: string) => void): (() => void) => { + openPathListeners.add(listener); + return () => { + openPathListeners.delete(listener); + }; + }, + 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/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/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..b40fc97f 100644 --- a/packages/web/src/client/harness-client.ts +++ b/packages/web/src/client/harness-client.ts @@ -1,4 +1,6 @@ import { + chatAnswersSchema, + type markReadBodySchema, personaAnswerSchema, personaListSchema, type Persona, @@ -10,12 +12,17 @@ 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); } +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); } @@ -45,3 +52,9 @@ export function postMessage(org: string, personaId: string, text: string) { {text} satisfies z.input, ); } + +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 f809b05e..143a0bd4 100644 --- a/packages/web/src/components/human-side.tsx +++ b/packages/web/src/components/human-side.tsx @@ -1,15 +1,22 @@ 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. +// 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 a33c0616..6d417a12 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,15 @@ 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 { + unreadIds: new Set(ids), + setViewing: () => undefined, + }; +} + +function renderSidebar(human: Human, entry = '/acme', unread = unreadOf([])) { const router = createMemoryRouter( [ { @@ -31,7 +40,9 @@ function renderSidebar(human: Human, entry = '/acme') { - + + + , @@ -77,6 +88,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 add91130..00aa4fed 100644 --- a/packages/web/src/components/nav.tsx +++ b/packages/web/src/components/nav.tsx @@ -24,10 +24,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[][] = [ @@ -59,6 +76,7 @@ export function AppSidebar() { const {pathname} = useLocation(); const human = useHuman(); const experiments = useExperiments(); + const unread = useUnread(); const {setOpenMobile} = useSidebar(); const home = `/${human?.org.slug ?? ''}`; const groups = GROUPS.map((group) => @@ -93,6 +111,9 @@ export function AppSidebar() { {destination.label} + {destination.to === CHAT && unread.unreadIds.size > 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..44884b15 --- /dev/null +++ b/packages/web/src/components/unread-provider.test.tsx @@ -0,0 +1,406 @@ +/// + +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'; +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 realHasFocus = document.hasFocus.bind(document); +const fetchMock = mock<(input: string, init?: RequestInit) => Promise>(); + +const LIST = '/api/orgs/acme/chat/answers'; +const READ_GENERAL = '/api/orgs/acme/chat/personas/general/read'; + +function persona(id: string, unread: boolean): ChatAnswer { + return {personaId: id, name: id, answer: {id: `${id}-answer`, text: 'Done.'}, unread}; +} + +// The api: the last answers as `listed` has them, and a read that lands, or fails where `read` +// 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)); + const status = typeof landed === 'number' ? landed : landed === false ? 503 : 204; + return Promise.resolve(new Response(null, {status})); + } + return Promise.resolve(Response.json({answers: 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.unreadIds.size} + {[...unread.unreadIds].join(',')} + + +
+ ); +} + +function renderProvider(experiments: Experiment[] = ['harness'], entry = '/acme') { + const router = createMemoryRouter( + [ + { + id: 'human', + loader: () => testHuman({...testOrg, experiments}), + element: , + children: [ + {path: '/account', element: }, + {path: '/:org', element: }, + ], + }, + ], + {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(); + 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: notifyDesktop, + onOpenPath: () => () => undefined, + setBadge, + }; + document.title = 'Personal Agent Proxy'; +}); + +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 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(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: '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'); + // 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); + }); + + 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(() => { + window.dispatchEvent(new Event('blur')); + }); + 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('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('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'); + + 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), answer: {id: answers.general, text: 'Filed.'}}, + {...persona('finance', !financeRead), answer: {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('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(() => { + expect(setBadge).toHaveBeenLastCalledWith(2); + }); + + unmount(); + 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..fda2b1e1 --- /dev/null +++ b/packages/web/src/components/unread-provider.tsx @@ -0,0 +1,236 @@ +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'; +import {useExperiments} from '@/hooks/use-experiment'; +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, 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; +// 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(); +} + +// An answer first seen unread, at a time, waiting to be told of. +type Waiting = {answerId: string; since: number}; + +// 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); +} + +export function UnreadProvider({children}: {children: ReactNode}) { + // 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 [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. + 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); + // 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 (signal: AbortSignal) => { + latestPoll.current += 1; + const asked = latestPoll.current; + const result = await listAnswers(org); + if (signal.aborted || asked !== latestPoll.current || result.isErr()) { + return; + } + const listed = result.value.answers; + setAnswers((kept) => (sameAnswers(kept, listed) ? kept : listed)); + if (resendAfterPoll.current) { + resendAfterPoll.current = false; + setResends((count) => count + 1); + } + 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(askNow, Math.max(0, confirmAt - Date.now())); + return () => { + clearTimeout(timer); + }; + }, [confirmAt, askNow]); + + useEffect(() => { + const onChange = () => setAttending(isAttending()); + document.addEventListener('visibilitychange', onChange); + window.addEventListener('focus', onChange); + window.addEventListener('blur', onChange); + return () => { + document.removeEventListener('visibilitychange', onChange); + window.removeEventListener('focus', onChange); + window.removeEventListener('blur', onChange); + }; + }, []); + + // 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, unless the api refused it. + const sent = useRef(null); + useEffect(() => { + if (!enabled || !answers || !looking?.answerId) { + return; + } + const {personaId, answerId} = looking; + const known = answers.find((answer) => answer.personaId === personaId); + const read = known !== undefined && !known.unread && known.answer.id === answerId; + if (read || sent.current === answerId) { + return; + } + sent.current = answerId; + void markRead(org, personaId, answerId).then((result) => { + if (result.isErr()) { + if (mayLandLater(result.error)) { + sent.current = null; + resendAfterPoll.current = true; + } + return; + } + askNow(); + }); + }, [enabled, org, looking, answers, askNow, resends]); + + // 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( + (answers ?? []) + .filter((answer) => answer.unread && answer.personaId !== lookingAt) + .map((answer) => answer.personaId), + ), + [answers, lookingAt], + ); + const count = unreadIds.size; + + // 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) { + return; + } + document.title = `(${count}) ${title}`; + return () => { + document.title = title; + }; + }, [count]); + useEffect(() => { + desktopBridge()?.setBadge?.(count); + }, [count]); + useEffect( + () => () => { + desktopBridge()?.setBadge?.(0); + }, + [], + ); + + const value = useMemo(() => ({unreadIds, setViewing}), [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..b9fcf520 --- /dev/null +++ b/packages/web/src/hooks/use-poll.ts @@ -0,0 +1,58 @@ +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. + +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. 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 { + const askNow = useRef<() => void>(() => undefined); + 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(); + }; + askNow.current = run; + if (immediate) { + run(); + } + const timer = setInterval(run, intervalMs); + document.addEventListener('visibilitychange', onVisible); + 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/hooks/use-unread.ts b/packages/web/src/hooks/use-unread.ts new file mode 100644 index 00000000..e326fc64 --- /dev/null +++ b/packages/web/src/hooks/use-unread.ts @@ -0,0 +1,28 @@ +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). + */ +export type Unread = { + // 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 + // while the person looks at it is read. + setViewing: (viewing: Viewing | null) => void; +}; + +const NOTHING: Unread = { + unreadIds: new Set(), + setViewing: () => 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..02f1e45c 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; tag: 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..ab482e9a --- /dev/null +++ b/packages/web/src/lib/notifications.test.ts @@ -0,0 +1,161 @@ +/// + +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, `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; +class FakeNotification { + static get permission() { + return permission; + } + static requestPermission = mock(() => Promise.resolve(permission)); + onclick: (() => void) | null = null; + close = mock(() => undefined); + constructor(title: string, options?: ShownOptions) { + if (!constructible) { + throw new TypeError('Illegal constructor.'); + } + shown.push({title, options}); + } +} + +const open = mock((_path: string) => undefined); +const notice = { + title: 'Finance', + body: 'Three invoices are due.', + path: '/acme/chat/finance', + tag: 'finance', + id: 'answer-1', +}; + +beforeEach(() => { + shown.length = 0; + permission = 'default'; + constructible = true; + open.mockClear(); + FakeNotification.requestPermission.mockClear(); + turnNotificationsOff(); + localStorage.clear(); + 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('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); + expect(shown).toEqual([]); + + await turnNotificationsOn(); + notify(notice, open); + expect(shown).toEqual([ + { + title: 'Finance', + options: {body: 'Three invoices are due.', tag: 'finance', renotify: true}, + }, + ]); + 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('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), + onUpdateReady: () => () => undefined, + restart: () => undefined, + notify: mock((_notice: Omit) => undefined), + onOpenPath: () => () => undefined, + setBadge: () => undefined, + }; + window.personalAgentProxyDesktop = bridge; + expect(notificationSupport()).toBe('desktop'); + + expect(await turnNotificationsOn()).toBe(true); + expect(FakeNotification.requestPermission).not.toHaveBeenCalled(); + notify(notice, open); + expect(bridge.notify).toHaveBeenCalledWith({ + title: 'Finance', + body: 'Three invoices are due.', + path: '/acme/chat/finance', + tag: 'finance', + }); + 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..44b8f4df --- /dev/null +++ b/packages/web/src/lib/notifications.ts @@ -0,0 +1,112 @@ +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 +// 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'; +// 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; 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'; + +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, 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 { + 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. */ +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 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, and alerts again. + */ +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; + } + const bridge = desktopBridge(); + if (bridge?.notify) { + localStorage.setItem(toldKey, id); + bridge.notify(shownNotice); + 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, options)); + if (made.isErr()) { + return; + } + localStorage.setItem(toldKey, id); + const shown = made.value; + 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 50fb5db0..920dc629 100644 --- a/packages/web/src/pages/account-page.tsx +++ b/packages/web/src/pages/account-page.tsx @@ -15,6 +15,7 @@ import {deleteAccount, deleteOtherSessions, deleteSession} from '@/client/me-cli import {AppShell, PageTitle} from '@/components/app-shell'; import {DangerCard} from '@/components/danger-card'; import {IconButton} from '@/components/icon-button'; +import {NotificationSetting} from '@/components/notification-setting'; import {OrgTile} from '@/components/org-switcher'; import {RowList} from '@/components/row-list'; import {Section} from '@/components/section'; @@ -25,11 +26,14 @@ import {useApply} from '@/hooks/use-apply'; import {useHuman} from '@/hooks/use-human'; import {formatDate} from '@/lib/format'; 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, every browser and desktop app they are -// signed in on, each to sign out, 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, every browser and desktop app they are signed in on, each to sign out, and deleting +// the account, which deletes every one they own. export function AccountPage() { const human = useHuman(); const {sessions} = useLoaderData(); @@ -52,6 +56,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}> @@ -69,6 +74,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..c03a7d58 100644 --- a/packages/web/src/pages/chat-page.test.tsx +++ b/packages/web/src/pages/chat-page.test.tsx @@ -1,30 +1,48 @@ /// -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'; -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'; -import {testHuman} from '@/test-org'; +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, 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, + }; } 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(); - fetchMock.mockImplementation(() => Promise.resolve(new Response(null, {status: 204}))); + 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}) + : new Response(null, {status: 204}), + ), + ); globalThis.fetch = ((input: string, init?: RequestInit) => fetchMock(input, init)) as typeof fetch; }); @@ -34,19 +52,26 @@ afterEach(() => { globalThis.fetch = realFetch; }); -function renderChatPage() { +function renderChatPage({ + personas = [general, finance], + entries = [], +}: {personas?: ChatPersona[]; entries?: ThreadLog['entries']} = {}) { const router = createMemoryRouter( [ { id: 'human', - loader: () => testHuman(), + loader: () => testHuman({...testOrg, experiments: ['harness']}), element: , children: [ { 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 +225,52 @@ 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 () => { + 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/}); + 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(); + }); + + 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', body: JSON.stringify({entryId: 'e2'})}), + ); + }); + expect( + fetchMock.mock.calls.filter(([url]) => url === '/api/orgs/acme/chat/personas/general/read'), + ).toHaveLength(1); + }); + + 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'}], + }); + + 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 1c8e4545..6f560b62 100644 --- a/packages/web/src/pages/chat-page.tsx +++ b/packages/web/src/pages/chat-page.tsx @@ -42,6 +42,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'; @@ -125,8 +126,9 @@ function lastOf(log: ThreadLog): ChatPersona['lastMessage'] { } // 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 +155,11 @@ 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. + const isUnread = (persona: ChatPersona) => + persona.id !== current && unread.unreadIds.has(persona.id); return (
@@ -163,6 +169,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 && ( + + )} + ); @@ -352,6 +375,17 @@ function Conversation({ }; }, [readLog, log.busy]); + // 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(() => { + setViewing({personaId, answerId: lastAnswerId}); + return () => { + setViewing(null); + }; + }, [setViewing, personaId, lastAnswerId]); + const send = async (event: FormEvent) => { event.preventDefault(); const message = text.trim();