Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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!**
Expand All @@ -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

Expand All @@ -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

Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
2 changes: 2 additions & 0 deletions src/components/MessageList.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -38,6 +39,7 @@ const MESSAGE_COMPONENTS = {
secret_answer: SecretDialog,
result: ResultMessage,
summary: SummaryMessage,
recap: RecapMessage,
error: ErrorMessage,
discovery_scope: DiscoveryScopeMessage,
discovery_areas: DiscoveryAreaMessage,
Expand Down
16 changes: 16 additions & 0 deletions src/components/messages/RecapMessage.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
export default function RecapMessage({ text }) {
if (!text) return null;

return (
<div className="px-3 py-2">
<div className="border-l-2 border-gray-700/70 pl-3 space-y-1.5">
<div className="flex items-center gap-1.5 text-xs text-gray-500">
<span aria-hidden="true">♦</span>
<span className="font-medium">Recap</span>
</div>
{/* Backend strips Markdown; render as plain text. */}
<p className="text-xs text-gray-500 italic leading-relaxed">{text}</p>
</div>
</div>
);
}
21 changes: 21 additions & 0 deletions src/hooks/useAgent.js
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
87 changes: 87 additions & 0 deletions src/hooks/useRecap.js
Original file line number Diff line number Diff line change
@@ -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]);
}