From 3ce924c42ef99763533b1856b9ef0e32f0713767 Mon Sep 17 00:00:00 2001 From: Robin Schulz Date: Sun, 2 Aug 2026 18:15:07 +0200 Subject: [PATCH] - Add idle-triggered session recap (`useRecap` + `RecapMessage`) - POST `/api/sessions/{id}/recap` after N exchanges + quiet period - Restore recap messages on session reload - Document min backend version and recap in README/CHANGELOG --- CHANGELOG.md | 8 +++ README.md | 9 +-- package-lock.json | 2 +- package.json | 2 +- src/components/MessageList.jsx | 2 + src/components/messages/RecapMessage.jsx | 16 +++++ src/hooks/useAgent.js | 21 ++++++ src/hooks/useRecap.js | 87 ++++++++++++++++++++++++ 8 files changed, 141 insertions(+), 6 deletions(-) create mode 100644 src/components/messages/RecapMessage.jsx create mode 100644 src/hooks/useRecap.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 7107b45..167cfcc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,14 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +## [0.4.0] - 2026-08-02 + +Requires [AgentForge](https://github.com/bulletinmybeard/agent-forge) **[≥ 0.14.0](https://github.com/bulletinmybeard/agent-forge/releases/tag/v0.14.0)** for the recap endpoint (`POST /api/sessions/{id}/recap`). + +### Added + +- **Recap**: idle-triggered running summary under the last answer (`useRecap` + `RecapMessage`). After `RECAP_MESSAGE_THRESHOLD` complete user/assistant exchanges since the last recap (default **3**), waits `RECAP_IDLE_MS` of quiet (default **1 min**), then `POST`s the recap endpoint. Backend stores a volatile recap message so it survives reloads; plain text only (no Markdown) + ## [0.3.0] - 2026-07-19 Requires [AgentForge](https://github.com/bulletinmybeard/agent-forge) **≥ 0.13.0** for command permission profiles (`/api/permissions/profiles/*`). Command-permissions overrides (`/api/permissions/commands/*`) remain as in 0.12. diff --git a/README.md b/README.md index e8ff181..a8023a9 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE) [![Vite](https://img.shields.io/badge/Vite-8-646cff.svg?logo=vite&logoColor=white)](https://vitejs.dev/) [![Biome](https://img.shields.io/badge/lint%2Fformat-Biome-60a5fa.svg?logo=biome&logoColor=white)](https://biomejs.dev/) -[![Requires AgentForge](https://img.shields.io/badge/requires-AgentForge%200.13.0%2B-blueviolet)](https://github.com/bulletinmybeard/agent-forge/releases/tag/v0.13.0) +[![Requires AgentForge](https://img.shields.io/badge/requires-AgentForge%200.14.0%2B-blueviolet)](https://github.com/bulletinmybeard/agent-forge/releases/tag/v0.14.0) > [!NOTE] > **Experimental!** @@ -15,8 +15,8 @@ AgentForge WebUI is a React SPA for [AgentForge](https://github.com/bulletinmybe It's a pure frontend: it streams the agent's think > act > observe loop over the backend's `/ws/chat` WebSocket and calls its REST API for sessions, uploads, memory, and configs. It does nothing on its own — a running AgentForge backend is required! > [!IMPORTANT] -> **Backend version:** WebUI **0.3.0+** needs [AgentForge **v0.13.0**](https://github.com/bulletinmybeard/agent-forge/releases/tag/v0.13.0) or newer. -> The **Command Permissions** modal (overrides + **profiles**) depends on APIs and schema from that release (`/api/permissions/commands/*`, `/api/permissions/profiles/*`, session `source` filtering, etc.). Older backends will fail those endpoints (e.g. 404). +> **Backend version:** WebUI **0.4.0+** needs [AgentForge **v0.14.0**](https://github.com/bulletinmybeard/agent-forge/releases/tag/v0.14.0) or newer. +> **Recap** depends on `/api/sessions/{id}/recap` from that release. The **Command Permissions** modal (overrides + **profiles**) depends on APIs and schema from v0.13.0 (`/api/permissions/commands/*`, `/api/permissions/profiles/*`, session `source` filtering, etc.). Older backends will fail those endpoints (e.g., 404). ## Features @@ -30,6 +30,7 @@ It's a pure frontend: it streams the agent's think > act > observe loop over the - Botty side panel for passive, in-context suggestions and full semantic-searchable chat session history - Session sidebar, status bar, knowledge bar, memory settings, profile and provider selectors, and a help modal - Context-usage bar with one-click session compaction at the critical threshold +- **Recap**: idle-triggered running summary of the conversation in a muted block under the last answer, persisted so it survives reloads - Eager file uploads: paperclip, clipboard paste, or drag-and-drop with inline thumbnails. Unset attachments persist across reloads - GitHub-flavoured Markdown rendering and a Monaco-based inline prompt editor @@ -38,7 +39,7 @@ It's a pure frontend: it streams the agent's think > act > observe loop over the Prerequisites: - **Node 20.19+** (`engines` in `package.json`) -- A running [AgentForge](https://github.com/bulletinmybeard/agent-forge) backend **[v0.13.0](https://github.com/bulletinmybeard/agent-forge/releases/tag/v0.13.0) or newer** (`scripts/deploy-local.sh` brings the stack up with the web service on `:8200`) +- A running [AgentForge](https://github.com/bulletinmybeard/agent-forge) backend **[v0.14.0](https://github.com/bulletinmybeard/agent-forge/releases/tag/v0.14.0) or newer** (`scripts/deploy-local.sh` brings the stack up with the web service on `:8200`) ```bash npm install diff --git a/package-lock.json b/package-lock.json index fd349d7..f61a8a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "agent-forge-webui", - "version": "0.3.0", + "version": "0.4.0", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index deaa5bb..c617abf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-forge-webui", - "version": "0.3.0", + "version": "0.4.0", "description": "React SPA front-end for AgentForge — every mode, tool call, and connector in one chat UI", "type": "module", "engines": { diff --git a/src/components/MessageList.jsx b/src/components/MessageList.jsx index 055b4a3..e5755bc 100644 --- a/src/components/MessageList.jsx +++ b/src/components/MessageList.jsx @@ -16,6 +16,7 @@ import FileDiffMessage from "./messages/FileDiffMessage"; import ModelFallbackMessage from "./messages/ModelFallbackMessage"; import MonitorJobMessage from "./messages/MonitorJobMessage"; import QueryMessage from "./messages/QueryMessage"; +import RecapMessage from "./messages/RecapMessage"; import ResearchPlanMessage from "./messages/ResearchPlanMessage"; import ResultMessage from "./messages/ResultMessage"; import RoutingMessage from "./messages/RoutingMessage"; @@ -38,6 +39,7 @@ const MESSAGE_COMPONENTS = { secret_answer: SecretDialog, result: ResultMessage, summary: SummaryMessage, + recap: RecapMessage, error: ErrorMessage, discovery_scope: DiscoveryScopeMessage, discovery_areas: DiscoveryAreaMessage, diff --git a/src/components/messages/RecapMessage.jsx b/src/components/messages/RecapMessage.jsx new file mode 100644 index 0000000..05238cf --- /dev/null +++ b/src/components/messages/RecapMessage.jsx @@ -0,0 +1,16 @@ +export default function RecapMessage({ text }) { + if (!text) return null; + + return ( +
+
+
+ + Recap +
+ {/* Backend strips Markdown; render as plain text. */} +

{text}

+
+
+ ); +} diff --git a/src/hooks/useAgent.js b/src/hooks/useAgent.js index f57c0b8..0151368 100644 --- a/src/hooks/useAgent.js +++ b/src/hooks/useAgent.js @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { useParams } from "react-router-dom"; import { v7 as uuidv7 } from "uuid"; import ws from "../lib/ws"; +import useRecap from "./useRecap"; export const useAgent = () => { const { sessionId: urlSessionId } = useParams(); @@ -1414,8 +1415,20 @@ export const useAgent = () => { const savedNoteTsSet = commandNotes.size > 0 ? new Set(commandNotes.keys()) : null; + const sessionId = sessionInfo.sessionId ?? urlSessionId ?? null; + + const handleRecap = useCallback( + (text, data) => { + addMessage({ type: "recap", text, covered: data?.covered ?? null }); + }, + [addMessage], + ); + + useRecap({ sessionId, running, messages, confirm, secret, onRecap: handleRecap }); + return { connected, + sessionId, messages, running, confirm, @@ -1579,6 +1592,14 @@ const restoreMessages = (dbMessages) => { }); break; + case "recap": + restored.push({ + type: "recap", + text: msg.content || meta.text || "", + _ts: `${new Date(msg.created_at).getTime()}-${seq}`, + }); + break; + case "summary": restored.push({ type: "summary", diff --git a/src/hooks/useRecap.js b/src/hooks/useRecap.js new file mode 100644 index 0000000..118aefb --- /dev/null +++ b/src/hooks/useRecap.js @@ -0,0 +1,87 @@ +import { useEffect, useRef } from "react"; + +// How long the conversation must sit untouched before a recap is requested. +// export const RECAP_IDLE_MS = 180000; // 3 min +export const RECAP_IDLE_MS = 60000; // 1 min + +// Complete user -> assistant exchanges required before a recap fires. 3 means +// three prompts that each got an answer; two back-and-forths won't trigger one. +// Counted since the last recap, so the gate keeps applying to every later recap +// rather than only the first. +export const RECAP_MESSAGE_THRESHOLD = 3; + +/** + * Count complete exchanges since the most recent recap. + * + * A prompt still awaiting its answer doesn't count, hence the min() of the two + * tallies. If the last recap has scrolled out of the loaded page the count comes + * out too high, which is harmless: the backend owns the real watermark and + * answers `created: false` when there is nothing new. + */ +export const exchangesSinceRecap = (messages) => { + let queries = 0; + let results = 0; + for (let i = messages.length - 1; i >= 0; i--) { + const type = messages[i]?.type; + if (type === "recap") break; + if (type === "query") queries++; + else if (type === "result") results++; + } + return Math.min(queries, results); +}; + +/** + * Request a recap once the conversation has gone quiet. + * + * Two gates: RECAP_MESSAGE_THRESHOLD complete exchanges must have happened since + * the last recap, and the conversation must then sit idle for RECAP_IDLE_MS. Below + * the threshold no timer is armed at all. + * + * The timer is re-armed from scratch whenever anything changes — a new message, + * a run starting, a pending confirm/secret prompt — so it only ever fires after + * a genuine idle stretch following an assistant response. + * + * The endpoint is incremental and no-ops when nothing new exists, so an extra + * fire is harmless; we still skip re-arming when the last message is already a + * recap, to avoid a pointless request every interval while the tab sits open. + */ +export default function useRecap({ sessionId, running, messages, confirm, secret, onRecap }) { + const timerRef = useRef(null); + + const last = messages.length > 0 ? messages[messages.length - 1] : null; + const lastTs = last?._ts ?? null; + const lastIsRecap = last?.type === "recap"; + const exchanges = exchangesSinceRecap(messages); + const enoughExchanges = exchanges >= Math.max(1, RECAP_MESSAGE_THRESHOLD); + const blocked = running || !!confirm || !!secret; + + useEffect(() => { + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + if (!sessionId || blocked || !enoughExchanges || !lastTs || lastIsRecap) return; + + let cancelled = false; + timerRef.current = setTimeout(async () => { + try { + const resp = await fetch(`/api/sessions/${sessionId}/recap`, { method: "POST" }); + if (!resp.ok) return; + const data = await resp.json(); + if (!cancelled && data?.created && data.recap) { + onRecap(data.recap, data); + } + } catch { + // A recap is chrome — a failure must never surface into the chat. + } + }, RECAP_IDLE_MS); + + return () => { + cancelled = true; + if (timerRef.current) { + clearTimeout(timerRef.current); + timerRef.current = null; + } + }; + }, [sessionId, blocked, enoughExchanges, lastTs, lastIsRecap, onRecap]); +}