From e51f2aa9a44efa919621dd0ad61c40674f68708d Mon Sep 17 00:00:00 2001 From: Benjamin Toby Date: Wed, 28 Oct 2026 04:14:13 +0100 Subject: [PATCH] Catch up on replies missed while the app was in the background The reply catch-up read the page's stale face, so after an interim message it stopped while the thinking row stayed up. It now reads the live face, checks at once when the page comes back into view or online, and replays the bot's face and task too, so a reply that ended silently can't leave the bot working on screen. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 2 +- .../[bot_id]/(hooks)/use-reply-catch-up.ts | 54 +++++++++++++++---- .../bots/[bot_id]/(sections)/conversation.tsx | 6 ++- src/pages/api/admin/bots/session-messages.ts | 26 +++++---- src/types/index.ts | 11 +++- 5 files changed, 74 insertions(+), 25 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e2d3e57..7d3ec2a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,7 +91,7 @@ Enables SQLite foreign keys (`enableForeignKeys`), `bunextInit()`, `ensureDocker - **Sessions:** one thread per user per bot in its own chat plus one per workspace; split into `bot_sessions` (rollover at the app setting `bot_session_max_messages`, default `AppData.BotSessionMaxMessages`, via `grabBotSessionMaxMessages`; titled and summarised by `name-bot-session.ts` from the session's opening (`grabSessionOpening`; `grabBotMessages` reads the latest), summary carried into the next session only when they're linked by `previous_session_id`, which only a carry-over sets; older implicit links are cleared at boot by `clearImplicitSessionLinks`). New session (`startBotSession`) and a conversation with no active session always start fresh. The rollover carries over like the bot does (`rollOverBotSession` -> `continueBotSession`, falling back to the stored summary; kept for the next message while a job is open in the full session or there's nothing to carry), inside send-message, so the user's message lands under the summary. Helpers in `src/functions/backend/db/bot-sessions/`. Job results land in the job's own session, even after it closes, except a reply that called `continue_in_new_session` (`carryOverBotSession`; the menu's and sessions modal's Continue in new session does the same through `continueBotSession`, the model writing title and summary, refused while a job is open in the session): its summary is kept on the closed session and opens the new one as a bot message (`carried_from_session_id`), the job's `session_id` moves so the answer follows, the new session is titled by the bot then (it has no first exchange to be named from), and the old one can never be reopened (`reopenBotSession` refuses; a thread showing it swaps its composer for a link to the new one). Session lists (rail, sessions modal) reopen a session on click, as before; a carried-over one can't be, so it opens to read (`?session_id=`, `conversationHref`). The page tells them which session it shows (`announceShownSession` -> `useShownSession`): they reload, mark it Current (greyed out; without `?session_id=` the active one is), list it even when it's older than the rail's latest, and the active row then leads back. From half the session length the reply prompt suggests moving on (`longSessionNote`). Earlier sessions never enter the prompt (only the last one's summary): the thread can open them above a session (`useOlderMessages.loadPrevious`, a divider per session, quote and reply as usual), and replies get `search_past_sessions`/`read_past_session` (scoped to that user, bot and place, never the current session) when the conversation has any. - **Coordinator:** exactly one, the super admin's alone, created idempotently by `ensureCoordinatorBot`. Default prompt `src/data/coordinator-system-prompt.ts`, backfilled only when empty. - **Routes:** `/admin/bots` reopens the last visited bot (cookie `cobble_last_visited_bot_id`, never a workspace conversation) if still reachable, else coordinator (super admin) / latest bot / first workspace / empty. `/admin/bots/[bot_id]` conversation (header, thread and composer at 760px, no right panel; `?session_id=` opens another of the user's sessions there: a closed one is reopened, except a carried-over one, which shows with `SessionMovedBar` instead of the composer, pointing where the conversation went on); `/admin/bots/[bot_id]/models`; `/admin/workspaces/[id]` dashboard; `/admin/workspaces/[id]/chatrooms` that workspace's chatrooms; `/admin/workspaces/[id]/bots/[bot_id]` same conversation scoped to the workspace. -- **Conversation loading:** pages render everything server-side (`grabConversationProps`) **except the history**, which the client loads (`api/admin/bots/conversation-history`, merged with what's already shown, refetched on bfcache restore) and pages older messages on scroll. Rail lists go through `railBots`/`railWorkspaces` (no prompts/memory, previews cut). While a reply is due the page polls `session-messages` (`use-reply-catch-up`) in case a push was missed. +- **Conversation loading:** pages render everything server-side (`grabConversationProps`) **except the history**, which the client loads (`api/admin/bots/conversation-history`, merged with what's already shown, refetched on bfcache restore) and pages older messages on scroll. Rail lists go through `railBots`/`railWorkspaces` (no prompts/memory, previews cut). While a reply is due (read with the live face, never the page's) the page polls `session-messages` (`use-reply-catch-up`, also at once when the page comes back into view or online) in case a push was missed, replaying the messages and the bot's face and task as their pushes. - **Sending:** `api/admin/bots/send-message.ts` -> `enqueueBotReply` -> `wakeBotsQueue`. **Never import `src/bots-queue/index.ts` from a page or API route** (pulls the bunext runtime into page bundles). Optimistic bubble in `pending_messages` until the saved message replaces it. - **Composer:** Tiptap editor reading/writing markdown, drafts in `localStorage`, `@` bots / `#` workspaces written as `[bot:ID]`/`[workspace:ID]`. Enter sends (Mod+Enter in lists/code). `@tiptap/react` needs `use-sync-external-store`; the app ships an ESM stand-in in `packages/use-sync-external-store-esm` (file dependency + override; the Dockerfile copies `packages/` before `bun install`). - **Rendering:** markdown via `react-markdown` + `remark-gfm`, raw HTML never rendered, component map in `[bot_id]/(data)/message-markdown-components.tsx`. Inline refs, all linked by `link-computer-refs.ts` and rendered by chips in `(partials)/`: `[bot:ID]`, `[workspace:ID]`, `[computer:ID]`, `[file:ID]` (a saved file), `[folder:::]` (opens `/admin/files`), `[take-over:ID:mode]`, `[proposal:ID]`, `[hook-proposal:ID]`, `[secret:ID]` (masked env, Reveal/Copy), `[secret-request:ID]` (masked input card), `[graphic:ID]` (a bot graphic, framed inline), `[room:ID]` (a chatroom). Parsers in `src/utils/*-ref.ts`. A ref quoted alone in a code span still links; one inside longer code stays literal (`linkComputerRefs` splits out code first). A file chip opens a file from another bot's folder for a user who reaches that folder (`userReachesFileScope`, the same rule as its bytes), so answers passed on from chatrooms keep working. diff --git a/src/pages/admin/bots/[bot_id]/(hooks)/use-reply-catch-up.ts b/src/pages/admin/bots/[bot_id]/(hooks)/use-reply-catch-up.ts index e9fb2ab..ca58d7c 100644 --- a/src/pages/admin/bots/[bot_id]/(hooks)/use-reply-catch-up.ts +++ b/src/pages/admin/bots/[bot_id]/(hooks)/use-reply-catch-up.ts @@ -1,6 +1,10 @@ import { useEffect, useRef } from "react"; import { AppData } from "@/src/data/app-data"; -import type { BotMessageWithAttachments, WebSocketDataType } from "@/src/types"; +import type { + BotMessageWithAttachments, + SessionMessagesResponse, + WebSocketDataType, +} from "@/src/types"; import postSessionApi from "@/src/functions/frontend/post-session-api"; type Params = { @@ -19,9 +23,13 @@ type Params = { * While a reply is due, checks the session's latest messages every * `AppData.BotReplyCatchUpMs`. Replies arrive live; this only catches one * whose push was missed (the socket was down at the time), so the - * thinking row can't stay up until a reload. A missed message is replayed + * thinking row can't stay up until a reload. It also checks at once when + * the page comes back into view or online. A missed message is replayed * as the `server:bot-message` it would have been, so the thread, the rail - * and the unread flag all take it the usual way. + * and the unread flag all take it the usual way. The bot's face, status + * line and active task are replayed too, as `server:bot-updated`, so a + * reply that ended without a final message (its face push missed) can't + * leave the bot working on screen. */ export default function useReplyCatchUp({ session_id, awaiting_reply, last_message_id }: Params) { const last_message = useRef(Number(last_message_id) || 0); @@ -33,28 +41,52 @@ export default function useReplyCatchUp({ session_id, awaiting_reply, last_messa let stale = false; - const timer = setInterval(async () => { - const res = await postSessionApi({ + const replay = ({ data }: { data: WebSocketDataType }) => + window.dispatchEvent(new CustomEvent("wsDataEvent", { detail: { data, message: data } })); + + const check = async () => { + const res = (await postSessionApi({ path: "session-messages", body: { session_id }, - }).catch(() => null); + }).catch(() => null)) as SessionMessagesResponse | null; if (stale || !res?.success || !Array.isArray(res.payload)) return; - const missed = (res.payload as BotMessageWithAttachments[]).filter( + const missed = res.payload.filter( (message) => Number(message.id) > last_message.current, ); for (const message of missed) { - const data: WebSocketDataType = { event: "server:bot-message", data: message }; - - window.dispatchEvent(new CustomEvent("wsDataEvent", { detail: { data, message: data } })); + replay({ data: { event: "server:bot-message", data: message } }); } - }, AppData.BotReplyCatchUpMs); + + /** + * After the messages, so a reply that ended silently (its last + * word an interim one) drops the row once its face moves on. + */ + if (res.bot?.id) replay({ data: { event: "server:bot-updated", data: res.bot } }); + }; + + /** + * Coming back to the app (a notification tapped, the PWA brought + * forward) checks at once: the socket was likely down meanwhile. + */ + const onVisible = () => { + if (document.visibilityState == "visible") check(); + }; + + const timer = setInterval(check, AppData.BotReplyCatchUpMs); + + window.addEventListener("online", check); + window.addEventListener("pageshow", check); + document.addEventListener("visibilitychange", onVisible); return () => { stale = true; clearInterval(timer); + window.removeEventListener("online", check); + window.removeEventListener("pageshow", check); + document.removeEventListener("visibilitychange", onVisible); }; }, [awaiting_reply, session_id]); } diff --git a/src/pages/admin/bots/[bot_id]/(sections)/conversation.tsx b/src/pages/admin/bots/[bot_id]/(sections)/conversation.tsx index 0c45572..bc6a202 100644 --- a/src/pages/admin/bots/[bot_id]/(sections)/conversation.tsx +++ b/src/pages/admin/bots/[bot_id]/(sections)/conversation.tsx @@ -197,11 +197,13 @@ export default function Conversation() { const last_thread_message = thread_messages[thread_messages.length - 1]; /** * A session moved on from gets no reply here: the bot answered in the - * new one. + * new one. The face is the live one, as the thinking row reads it: the + * page's is from when it rendered, so after an interim message the + * catch-up would stop while the row stays up. */ const awaiting_reply = !moved_to_session_id && - isAwaitingReply({ last_message: last_thread_message, face: bot?.face }); + isAwaitingReply({ last_message: last_thread_message, face: live_bot?.face }); const reply_progress = useReplyProgress({ bot_id: bot?.id || 0, diff --git a/src/pages/api/admin/bots/session-messages.ts b/src/pages/api/admin/bots/session-messages.ts index ecd67cc..ac08513 100644 --- a/src/pages/api/admin/bots/session-messages.ts +++ b/src/pages/api/admin/bots/session-messages.ts @@ -1,24 +1,23 @@ import userAuth from "@/src/functions/backend/auth/user-auth"; import grabUserBotSession from "@/src/functions/backend/db/bot-sessions/grab-user-bot-session"; import grabBotMessages from "@/src/functions/backend/db/bots/grab-bot-messages"; +import grabBotFace from "@/src/functions/backend/db/bots/grab-bot-face"; +import grabActiveBotTask from "@/src/functions/backend/db/bots/grab-active-bot-task"; import { AppData } from "@/src/data/app-data"; import { BotSessionIdRequestSchema } from "@/src/schemas/bot-schema"; -import type { BotMessageWithAttachments } from "@/src/types"; +import type { SessionMessagesResponse } from "@/src/types"; import AppError from "@/src/utils/app-error"; import parseWithSchema from "@/src/utils/parse-with-schema"; import safeErrorMessage from "@/src/utils/safe-error-message"; -import type { - APIResponseObject, - BunextAPIRouteHandler, -} from "@moduletrace/bunext/types"; +import type { BunextAPIRouteHandler } from "@moduletrace/bunext/types"; /** - * The latest messages of one of the session user's own sessions, so the - * open conversation can catch up on a reply whose live push it missed. + * The latest messages of one of the session user's own sessions, and the + * bot's face, status line and active task for that user (as + * `server:bot-updated` carries them), so the open conversation can catch + * up on a reply or a face change whose live push it missed. */ -export const handler: BunextAPIRouteHandler< - APIResponseObject -> = async ({ req, body }) => { +export const handler: BunextAPIRouteHandler = async ({ req, body }) => { try { if (req.method != "POST") { throw new AppError(`Method not allowed`); @@ -38,6 +37,8 @@ export const handler: BunextAPIRouteHandler< throw new AppError(`Session not found.`); } + const bot_id = Number(session.bot_id); + return { success: true, payload: await grabBotMessages({ @@ -45,6 +46,11 @@ export const handler: BunextAPIRouteHandler< limit: AppData.BotReplyCatchUpMessages, user_id: user.id, }), + bot: { + id: bot_id, + ...(await grabBotFace({ bot_id, user_id: user.id })), + task: await grabActiveBotTask({ bot_id, user_id: user.id }), + }, }; } catch (error: unknown) { return { success: false, msg: safeErrorMessage({ error }) }; diff --git a/src/types/index.ts b/src/types/index.ts index 043ff15..4a438f3 100755 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -28,7 +28,7 @@ import type { WebsocketRooms } from "../dict/websocket-rooms"; import type { LucideIcon } from "lucide-react"; import type { ServerQueryParam } from "@moduletrace/bun-sqlite"; import type { MediaParadigms, MediaTypes } from "../dict/media-dict"; -import type { BunextPageModuleServerReturnURLObject } from "@moduletrace/bunext/types"; +import type { APIResponseObject, BunextPageModuleServerReturnURLObject } from "@moduletrace/bunext/types"; import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "./sql-joins"; import type { ImageInputToBase64FunctionReturn } from "@moduletrace/twui/utils/form/imageInputToBase64"; import type { HarnessKind } from "../dict/harness-dict"; @@ -477,6 +477,15 @@ export type BotUpdatedData = { admin_workspace_ids?: number[]; }; +/** + * `api/admin/bots/session-messages`: the session's latest messages, and + * the bot as the session's user sees it now (face, status line, active + * task), so a conversation that missed pushes catches up on both. + */ +export type SessionMessagesResponse = APIResponseObject & { + bot?: BotUpdatedData | null; +}; + /** * `server:workspace-updated` data: a workspace's picture changed, so the * rail and its header show the new one.