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 <[email protected]>
This commit is contained in:
2026-10-28 04:14:13 +01:00
co-authored by Claude Opus 5.5
parent 4061a317bc
commit e51f2aa9a4
5 changed files with 74 additions and 25 deletions
+1 -1
View File
@@ -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. - **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. - **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. - **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. - **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`). - **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:<volume>:<owner>:<path>]` (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. - **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:<volume>:<owner>:<path>]` (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.
@@ -1,6 +1,10 @@
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { AppData } from "@/src/data/app-data"; 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"; import postSessionApi from "@/src/functions/frontend/post-session-api";
type Params = { type Params = {
@@ -19,9 +23,13 @@ type Params = {
* While a reply is due, checks the session's latest messages every * While a reply is due, checks the session's latest messages every
* `AppData.BotReplyCatchUpMs`. Replies arrive live; this only catches one * `AppData.BotReplyCatchUpMs`. Replies arrive live; this only catches one
* whose push was missed (the socket was down at the time), so the * 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 * 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) { export default function useReplyCatchUp({ session_id, awaiting_reply, last_message_id }: Params) {
const last_message = useRef(Number(last_message_id) || 0); 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; let stale = false;
const timer = setInterval(async () => { const replay = ({ data }: { data: WebSocketDataType }) =>
const res = await postSessionApi<BotMessageWithAttachments>({ window.dispatchEvent(new CustomEvent("wsDataEvent", { detail: { data, message: data } }));
const check = async () => {
const res = (await postSessionApi<BotMessageWithAttachments>({
path: "session-messages", path: "session-messages",
body: { session_id }, body: { session_id },
}).catch(() => null); }).catch(() => null)) as SessionMessagesResponse | null;
if (stale || !res?.success || !Array.isArray(res.payload)) return; 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, (message) => Number(message.id) > last_message.current,
); );
for (const message of missed) { for (const message of missed) {
const data: WebSocketDataType = { event: "server:bot-message", data: message }; replay({ data: { event: "server:bot-message", data: message } });
window.dispatchEvent(new CustomEvent("wsDataEvent", { detail: { data, message: data } }));
} }
}, 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 () => { return () => {
stale = true; stale = true;
clearInterval(timer); clearInterval(timer);
window.removeEventListener("online", check);
window.removeEventListener("pageshow", check);
document.removeEventListener("visibilitychange", onVisible);
}; };
}, [awaiting_reply, session_id]); }, [awaiting_reply, session_id]);
} }
@@ -197,11 +197,13 @@ export default function Conversation() {
const last_thread_message = thread_messages[thread_messages.length - 1]; const last_thread_message = thread_messages[thread_messages.length - 1];
/** /**
* A session moved on from gets no reply here: the bot answered in the * 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 = const awaiting_reply =
!moved_to_session_id && !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({ const reply_progress = useReplyProgress({
bot_id: bot?.id || 0, bot_id: bot?.id || 0,
+16 -10
View File
@@ -1,24 +1,23 @@
import userAuth from "@/src/functions/backend/auth/user-auth"; import userAuth from "@/src/functions/backend/auth/user-auth";
import grabUserBotSession from "@/src/functions/backend/db/bot-sessions/grab-user-bot-session"; 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 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 { AppData } from "@/src/data/app-data";
import { BotSessionIdRequestSchema } from "@/src/schemas/bot-schema"; 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 AppError from "@/src/utils/app-error";
import parseWithSchema from "@/src/utils/parse-with-schema"; import parseWithSchema from "@/src/utils/parse-with-schema";
import safeErrorMessage from "@/src/utils/safe-error-message"; import safeErrorMessage from "@/src/utils/safe-error-message";
import type { import type { BunextAPIRouteHandler } from "@moduletrace/bunext/types";
APIResponseObject,
BunextAPIRouteHandler,
} from "@moduletrace/bunext/types";
/** /**
* The latest messages of one of the session user's own sessions, so the * The latest messages of one of the session user's own sessions, and the
* open conversation can catch up on a reply whose live push it missed. * 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< export const handler: BunextAPIRouteHandler<SessionMessagesResponse> = async ({ req, body }) => {
APIResponseObject<BotMessageWithAttachments>
> = async ({ req, body }) => {
try { try {
if (req.method != "POST") { if (req.method != "POST") {
throw new AppError(`Method not allowed`); throw new AppError(`Method not allowed`);
@@ -38,6 +37,8 @@ export const handler: BunextAPIRouteHandler<
throw new AppError(`Session not found.`); throw new AppError(`Session not found.`);
} }
const bot_id = Number(session.bot_id);
return { return {
success: true, success: true,
payload: await grabBotMessages({ payload: await grabBotMessages({
@@ -45,6 +46,11 @@ export const handler: BunextAPIRouteHandler<
limit: AppData.BotReplyCatchUpMessages, limit: AppData.BotReplyCatchUpMessages,
user_id: user.id, 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) { } catch (error: unknown) {
return { success: false, msg: safeErrorMessage({ error }) }; return { success: false, msg: safeErrorMessage({ error }) };
+10 -1
View File
@@ -28,7 +28,7 @@ import type { WebsocketRooms } from "../dict/websocket-rooms";
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import type { ServerQueryParam } from "@moduletrace/bun-sqlite"; import type { ServerQueryParam } from "@moduletrace/bun-sqlite";
import type { MediaParadigms, MediaTypes } from "../dict/media-dict"; 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 { BUN_SQLITE_COBBLE_USERS_JOIN } from "./sql-joins";
import type { ImageInputToBase64FunctionReturn } from "@moduletrace/twui/utils/form/imageInputToBase64"; import type { ImageInputToBase64FunctionReturn } from "@moduletrace/twui/utils/form/imageInputToBase64";
import type { HarnessKind } from "../dict/harness-dict"; import type { HarnessKind } from "../dict/harness-dict";
@@ -477,6 +477,15 @@ export type BotUpdatedData = {
admin_workspace_ids?: number[]; 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<BotMessageWithAttachments> & {
bot?: BotUpdatedData | null;
};
/** /**
* `server:workspace-updated` data: a workspace's picture changed, so the * `server:workspace-updated` data: a workspace's picture changed, so the
* rail and its header show the new one. * rail and its header show the new one.