(
+ () => ({
+ code({ className: cls, children: kids, ...props }) {
+ const match = /language-(\w+)/.exec(cls || "");
+ if (match) {
+ const code = String(kids).replace(/\n$/, "");
+ return (
+
+ );
+ }
+ const raw = String(kids).replace(/\n$/, "");
+ /** Plain fenced ``` blocks (no language) & wide one-liners: block monospace, not inline pill. */
+ const widePlainBlock = raw.includes("\n") || raw.length > 120;
+ if (widePlainBlock) {
+ return (
+
+ {kids}
+
+ );
+ }
+ return (
+
+ {kids}
+
+ );
+ },
+ pre({ children: markdownChildren }) {
+ const kids = Children.toArray(markdownChildren);
+ const lone = kids.length === 1 ? kids[0] : null;
+ /** Highlighted fences render ``CodeBlock`` (block shell); skip invalid ````. */
+ if (lone != null && isValidElement(lone) && lone.type === CodeBlock) {
+ return <>{markdownChildren}>;
+ }
+ return (
+
+ {markdownChildren}
+
+ );
+ },
+ a({ href, children: markdownChildren, ...props }) {
+ return (
+
+ {markdownChildren}
+
+ );
+ },
+ }),
+ [highlightCode],
+ );
+
return (
;
- }
- const raw = String(kids).replace(/\n$/, "");
- /** Plain fenced ``` blocks (no language) & wide one-liners: block monospace, not inline pill. */
- const widePlainBlock = raw.includes("\n") || raw.length > 120;
- if (widePlainBlock) {
- return (
-
- {kids}
-
- );
- }
- return (
-
- {kids}
-
- );
- },
- pre({ children: markdownChildren }) {
- const kids = Children.toArray(markdownChildren);
- const lone = kids.length === 1 ? kids[0] : null;
- /** Highlighted fences render ``CodeBlock`` (block shell); skip invalid ``
``. */
- if (lone != null && isValidElement(lone) && lone.type === CodeBlock) {
- return <>{markdownChildren}>;
- }
- return (
-
- {markdownChildren}
-
- );
- },
- a({ href, children: markdownChildren, ...props }) {
- return (
-
- {markdownChildren}
-
- );
- },
- }}
+ remarkPlugins={remarkPlugins}
+ rehypePlugins={rehypePlugins}
+ components={components}
>
{children}
diff --git a/webui/src/components/MessageBubble.tsx b/webui/src/components/MessageBubble.tsx
index d5427ec4..98ab0c94 100644
--- a/webui/src/components/MessageBubble.tsx
+++ b/webui/src/components/MessageBubble.tsx
@@ -1,6 +1,5 @@
import {
useCallback,
- useDeferredValue,
useEffect,
useRef,
useState,
@@ -120,7 +119,7 @@ export function MessageBubble({
) : empty && message.isStreaming ? null : (
<>
-
{message.content}
+
{message.content}
{media.length > 0 ?
: null}
{showAssistantFooterRow ? (
@@ -480,8 +479,6 @@ export function ReasoningBubble({
embeddedInCluster = false,
}: ReasoningBubbleProps) {
const { t } = useTranslation();
- const deferredText = useDeferredValue(text);
- const markdownSource = streaming ? deferredText : text;
const [userToggled, setUserToggled] = useState(false);
const [openLocal, setOpenLocal] = useState(true);
const open = userToggled ? openLocal : streaming;
@@ -537,6 +534,7 @@ export function ReasoningBubble({
)}
>
- {markdownSource}
+ {text}
)}
diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx
index 96188e60..116b67d6 100644
--- a/webui/src/components/settings/SettingsView.tsx
+++ b/webui/src/components/settings/SettingsView.tsx
@@ -52,6 +52,13 @@ import type { SettingsPayload, WebSearchSettingsUpdate } from "@/lib/types";
type SettingsSectionKey = "general" | "byok";
type ByokPaneKey = "llm" | "web-search";
+const LOCAL_UNCONFIGURED_PROVIDER_ORDER = new Map(
+ ["vllm", "ollama", "lm_studio", "atomic_chat", "ovms"].map((name, index) => [
+ name,
+ index,
+ ]),
+);
+
interface SettingsViewProps {
theme: "light" | "dark";
onToggleTheme: () => void;
@@ -176,7 +183,8 @@ export function SettingsView({
if (!provider) return;
const providerForm = providerForms[providerName] ?? { apiKey: "", apiBase: "" };
const apiKey = providerForm.apiKey.trim();
- if (!provider.configured && !apiKey) {
+ const apiKeyRequired = provider.api_key_required ?? true;
+ if (!provider.configured && apiKeyRequired && !apiKey) {
setError(t("settings.byok.apiKeyRequired"));
return;
}
@@ -917,7 +925,10 @@ function ByokSettings({
const [activePane, setActivePane] = useState
("llm");
const [showAllUnconfigured, setShowAllUnconfigured] = useState(false);
const configuredProviders = settings.providers.filter((provider) => provider.configured);
- const unconfiguredProviders = settings.providers.filter((provider) => !provider.configured);
+ const unconfiguredProviders = useMemo(
+ () => orderUnconfiguredProviders(settings.providers.filter((provider) => !provider.configured)),
+ [settings.providers],
+ );
const initialUnconfiguredCount = 6;
const visibleUnconfiguredProviders = showAllUnconfigured
? unconfiguredProviders
@@ -935,6 +946,12 @@ function ByokSettings({
const saving = providerSaving === provider.name;
const keyVisible = !!visibleProviderKeys[provider.name];
const editingKey = !provider.configured || !!editingProviderKeys[provider.name];
+ const apiKeyRequired = provider.api_key_required ?? true;
+ const apiKey = form.apiKey.trim();
+ const apiBase = form.apiBase.trim();
+ const missingRequiredApiKey = apiKeyRequired && !provider.configured && !apiKey;
+ const missingOptionalCredential =
+ !apiKeyRequired && !provider.configured && !apiKey && !apiBase;
return (
onSaveProvider(provider.name)}
- disabled={saving || (!provider.configured && !form.apiKey.trim())}
+ disabled={saving || missingRequiredApiKey || missingOptionalCredential}
className="rounded-full"
>
{saving ? t("settings.actions.saving") : t("settings.actions.save")}
@@ -1188,6 +1205,25 @@ function ByokEmptyState({ children }: { children: ReactNode }) {
);
}
+function orderUnconfiguredProviders(
+ providers: SettingsPayload["providers"],
+): SettingsPayload["providers"] {
+ return providers
+ .map((provider, index) => ({ provider, index }))
+ .sort((left, right) => {
+ const rank = providerVisibilityRank(left.provider) - providerVisibilityRank(right.provider);
+ return rank || left.index - right.index;
+ })
+ .map(({ provider }) => provider);
+}
+
+function providerVisibilityRank(provider: SettingsPayload["providers"][number]): number {
+ const localRank = LOCAL_UNCONFIGURED_PROVIDER_ORDER.get(provider.name);
+ if (localRank !== undefined) return localRank;
+ if ((provider.api_key_required ?? true) === false) return 100;
+ return 200;
+}
+
const PROVIDER_ICONS: Record
= {
custom: Hexagon,
openrouter: Sparkles,
@@ -1212,6 +1248,12 @@ const PROVIDER_ICONS: Record = {
qianfan: Database,
azure_openai: Cloud,
bedrock: Database,
+ vllm: Cpu,
+ ollama: Cpu,
+ lm_studio: Cpu,
+ atomic_chat: Cpu,
+ ovms: Cpu,
+ nvidia: Zap,
};
function ProviderIcon({ provider }: { provider: string }) {
diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts
index 0e54544b..59ad8566 100644
--- a/webui/src/lib/types.ts
+++ b/webui/src/lib/types.ts
@@ -110,6 +110,7 @@ export interface SettingsPayload {
name: string;
label: string;
configured: boolean;
+ api_key_required?: boolean;
api_key_hint?: string | null;
api_base?: string | null;
default_api_base?: string | null;
diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx
index 7709c1c9..f6e3f8ae 100644
--- a/webui/src/tests/app-layout.test.tsx
+++ b/webui/src/tests/app-layout.test.tsx
@@ -198,8 +198,52 @@ describe("App layout", () => {
name: "openrouter",
label: "OpenRouter",
configured: false,
+ api_key_required: true,
default_api_base: "https://openrouter.ai/api/v1",
},
+ {
+ name: "azure_openai",
+ label: "Azure OpenAI",
+ configured: false,
+ api_key_required: true,
+ },
+ {
+ name: "huggingface",
+ label: "Hugging Face",
+ configured: false,
+ api_key_required: true,
+ },
+ {
+ name: "siliconflow",
+ label: "SiliconFlow",
+ configured: false,
+ api_key_required: true,
+ },
+ {
+ name: "volcengine",
+ label: "VolcEngine",
+ configured: false,
+ api_key_required: true,
+ },
+ {
+ name: "byteplus",
+ label: "BytePlus",
+ configured: false,
+ api_key_required: true,
+ },
+ {
+ name: "qianfan",
+ label: "Qianfan",
+ configured: false,
+ api_key_required: true,
+ },
+ {
+ name: "atomic_chat",
+ label: "Atomic Chat",
+ configured: false,
+ api_key_required: false,
+ default_api_base: "http://localhost:1337/v1",
+ },
],
web_search: {
provider: "brave",
@@ -254,6 +298,9 @@ describe("App layout", () => {
fireEvent.click(screen.getByText("OpenAI"));
expect(screen.getByText("open••••-key")).toBeInTheDocument();
expect(screen.queryByDisplayValue("unsaved-openai-key")).not.toBeInTheDocument();
+ fireEvent.click(screen.getByText("Atomic Chat"));
+ expect(screen.getByDisplayValue("http://localhost:1337/v1")).toBeInTheDocument();
+ expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
fireEvent.click(screen.getByRole("tab", { name: "Web Search" }));
expect(screen.getByText("Search provider")).toBeInTheDocument();
diff --git a/webui/src/tests/code-block.test.tsx b/webui/src/tests/code-block.test.tsx
index 2a96bf64..b76aeb0d 100644
--- a/webui/src/tests/code-block.test.tsx
+++ b/webui/src/tests/code-block.test.tsx
@@ -35,6 +35,18 @@ vi.mock("react-syntax-highlighter/dist/esm/styles/prism/one-light", () => ({
}));
describe("CodeBlock", () => {
+ it("renders plain code without mounting the highlighter when highlighting is disabled", () => {
+ render(
+
+
+ ,
+ );
+
+ expect(screen.queryByTestId("highlighted-code")).not.toBeInTheDocument();
+ expect(screen.getByText("const value = 1;")).toBeInTheDocument();
+ expect(screen.getByText("ts")).toBeInTheDocument();
+ });
+
it("reads theme from context without creating per-block observers", async () => {
const originalMutationObserver = globalThis.MutationObserver;
const observer = vi.fn();
diff --git a/webui/src/tests/markdown-text.test.tsx b/webui/src/tests/markdown-text.test.tsx
new file mode 100644
index 00000000..c818f2f5
--- /dev/null
+++ b/webui/src/tests/markdown-text.test.tsx
@@ -0,0 +1,82 @@
+import { act, render, screen } from "@testing-library/react";
+import { describe, expect, it, vi } from "vitest";
+
+import { MarkdownText } from "@/components/MarkdownText";
+
+const rendererSpy = vi.hoisted(() => vi.fn());
+
+vi.mock("@/components/MarkdownTextRenderer", () => ({
+ default: ({
+ children,
+ highlightCode,
+ }: {
+ children: string;
+ highlightCode?: boolean;
+ }) => {
+ rendererSpy({ children, highlightCode });
+ return (
+
+ {children}
+
+ );
+ },
+}));
+
+describe("MarkdownText", () => {
+ it("throttles streaming markdown commits and flushes before final highlighting", async () => {
+ rendererSpy.mockClear();
+ vi.useFakeTimers();
+ try {
+ const { rerender } = render(
+ hello,
+ );
+
+ await act(async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello");
+ expect(screen.getByTestId("markdown-renderer")).toHaveAttribute(
+ "data-highlight-code",
+ "false",
+ );
+ expect(rendererSpy).toHaveBeenCalledTimes(1);
+
+ rerender(hello world);
+ expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello");
+ expect(rendererSpy).toHaveBeenCalledTimes(1);
+
+ act(() => {
+ vi.advanceTimersByTime(79);
+ });
+ expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello");
+ expect(rendererSpy).toHaveBeenCalledTimes(1);
+
+ act(() => {
+ vi.advanceTimersByTime(1);
+ });
+ await act(async () => {
+ await Promise.resolve();
+ });
+
+ expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello world");
+ expect(rendererSpy).toHaveBeenCalledTimes(2);
+
+ rerender(hello world!!!);
+ expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello world");
+
+ rerender(hello world!!!);
+ expect(screen.getByTestId("markdown-renderer")).toHaveTextContent("hello world!!!");
+ expect(screen.getByTestId("markdown-renderer")).toHaveAttribute(
+ "data-highlight-code",
+ "true",
+ );
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+});
From c8bb04a8fec82c974bb92047e86b08f30012c1fa Mon Sep 17 00:00:00 2001
From: Xubin Ren <52506698+Re-bin@users.noreply.github.com>
Date: Sun, 17 May 2026 23:51:52 +0800
Subject: [PATCH 04/12] feat(webui): persist agent activity events
---
nanobot/agent/loop.py | 89 ++-----
nanobot/agent/runner.py | 38 +++
nanobot/channels/websocket.py | 22 +-
nanobot/utils/file_edit_events.py | 311 +++++++++++++++++++++++
nanobot/utils/progress_events.py | 19 +-
nanobot/utils/webui_titles.py | 138 ----------
nanobot/utils/webui_transcript.py | 109 +++++++-
nanobot/utils/webui_turn_helpers.py | 268 +++++++++++++++++++
tests/agent/test_loop_progress.py | 211 +++++++++++++++
tests/agent/test_loop_save_turn.py | 34 ++-
tests/channels/test_websocket_channel.py | 68 +++++
tests/utils/test_file_edit_events.py | 83 ++++++
tests/utils/test_webui_transcript.py | 56 ++++
13 files changed, 1238 insertions(+), 208 deletions(-)
create mode 100644 nanobot/utils/file_edit_events.py
delete mode 100644 nanobot/utils/webui_titles.py
create mode 100644 tests/utils/test_file_edit_events.py
diff --git a/nanobot/agent/loop.py b/nanobot/agent/loop.py
index bc807092..81cc393b 100644
--- a/nanobot/agent/loop.py
+++ b/nanobot/agent/loop.py
@@ -33,7 +33,6 @@ from nanobot.config.schema import AgentDefaults, ModelPresetConfig
from nanobot.providers.base import LLMProvider
from nanobot.providers.factory import ProviderSnapshot
from nanobot.session.goal_state import (
- goal_state_ws_blob,
runner_wall_llm_timeout_s,
)
from nanobot.session.manager import Session, SessionManager
@@ -44,8 +43,11 @@ from nanobot.utils.helpers import truncate_text as truncate_text_fn
from nanobot.utils.image_generation_intent import image_generation_prompt
from nanobot.utils.runtime import EMPTY_FINAL_RESPONSE_MESSAGE
from nanobot.utils.session_attachments import merge_turn_media_into_last_assistant
-from nanobot.utils.webui_titles import mark_webui_session, maybe_generate_webui_title_after_turn
-from nanobot.utils.webui_turn_helpers import publish_turn_run_status
+from nanobot.utils.webui_turn_helpers import (
+ WebuiTurnCoordinator,
+ build_bus_progress_callback,
+ mark_webui_session,
+)
if TYPE_CHECKING:
from nanobot.config.schema import (
@@ -237,6 +239,11 @@ class AgentLoop:
self.context = ContextBuilder(workspace, timezone=timezone, disabled_skills=disabled_skills)
self.sessions = session_manager or SessionManager(workspace)
+ self._webui_turns = WebuiTurnCoordinator(
+ bus=self.bus,
+ sessions=self.sessions,
+ schedule_background=lambda coro: self._schedule_background(coro),
+ )
self.tools = ToolRegistry()
# One file-read/write tracker per logical session. The tool registry is
# shared by this loop, so tools resolve the active state via contextvars.
@@ -524,34 +531,7 @@ class AgentLoop:
self, msg: InboundMessage
) -> Callable[..., Awaitable[None]]:
"""Build a progress callback that publishes to the message bus."""
-
- async def _bus_progress(
- content: str,
- *,
- tool_hint: bool = False,
- tool_events: list[dict[str, Any]] | None = None,
- reasoning: bool = False,
- reasoning_end: bool = False,
- ) -> None:
- meta = dict(msg.metadata or {})
- meta["_progress"] = True
- meta["_tool_hint"] = tool_hint
- if reasoning:
- meta["_reasoning_delta"] = True
- if reasoning_end:
- meta["_reasoning_end"] = True
- if tool_events:
- meta["_tool_events"] = tool_events
- await self.bus.publish_outbound(
- OutboundMessage(
- channel=msg.channel,
- chat_id=msg.chat_id,
- content=content,
- metadata=meta,
- )
- )
-
- return _bus_progress
+ return build_bus_progress_callback(self.bus, msg)
async def _build_retry_wait_callback(
self, msg: InboundMessage
@@ -938,38 +918,12 @@ class AgentLoop:
content="", metadata=msg.metadata or {},
))
if msg.channel == "websocket":
- # Signal that the turn is fully complete (all tools executed,
- # final text streamed). This lets WS clients know when to
- # definitively stop the loading indicator.
turn_lat = self._pending_turn_latency_ms.pop(session_key, None)
- turn_metadata: dict[str, Any] = {**msg.metadata, "_turn_end": True}
- if turn_lat is not None:
- turn_metadata["latency_ms"] = int(turn_lat)
- sess_turn = self.sessions.get_or_create(session_key)
- turn_metadata["goal_state"] = goal_state_ws_blob(sess_turn.metadata)
- await self.bus.publish_outbound(OutboundMessage(
- channel=msg.channel, chat_id=msg.chat_id,
- content="", metadata=turn_metadata,
- ))
- if msg.metadata.get("webui") is True:
- async def _generate_title_and_notify() -> None:
- generated = await maybe_generate_webui_title_after_turn(
- channel=msg.channel,
- metadata=msg.metadata,
- sessions=self.sessions,
- session_key=session_key,
- provider=self.provider,
- model=self.model,
- )
- if generated:
- await self.bus.publish_outbound(OutboundMessage(
- channel=msg.channel,
- chat_id=msg.chat_id,
- content="",
- metadata={**msg.metadata, "_session_updated": True},
- ))
-
- self._schedule_background(_generate_title_and_notify())
+ await self._webui_turns.handle_turn_end(
+ msg,
+ session_key=session_key,
+ latency_ms=turn_lat,
+ )
except asyncio.CancelledError:
logger.info("Task cancelled for session {}", session_key)
# Preserve partial context from the interrupted turn so
@@ -1021,8 +975,9 @@ class AgentLoop:
"Re-published {} leftover message(s) to bus for session {}",
leftover, session_key,
)
- await publish_turn_run_status(self.bus, msg, "idle")
+ await self._webui_turns.publish_run_status(msg, "idle")
self._pending_turn_latency_ms.pop(session_key, None)
+ self._webui_turns.discard(session_key)
async def close_mcp(self) -> None:
"""Drain pending background archives, then close MCP connections."""
@@ -1338,6 +1293,12 @@ class AgentLoop:
"include_timestamps": True,
}
ctx.history = ctx.session.get_history(**_hist_kwargs)
+ self._webui_turns.capture_title_context(
+ ctx.session_key,
+ ctx.msg,
+ self.provider,
+ self.model,
+ )
ctx.initial_messages = self._build_initial_messages(
ctx.msg, ctx.session, ctx.history, ctx.pending_summary
@@ -1354,7 +1315,7 @@ class AgentLoop:
return "ok"
async def _state_run(self, ctx: TurnContext) -> str:
- await publish_turn_run_status(self.bus, ctx.msg, "running")
+ await self._webui_turns.publish_run_status(ctx.msg, "running")
result = await self._run_agent_loop(
ctx.initial_messages,
on_progress=ctx.on_progress,
diff --git a/nanobot/agent/runner.py b/nanobot/agent/runner.py
index 56482f75..64345822 100644
--- a/nanobot/agent/runner.py
+++ b/nanobot/agent/runner.py
@@ -15,6 +15,12 @@ from loguru import logger
from nanobot.agent.hook import AgentHook, AgentHookContext
from nanobot.agent.tools.registry import ToolRegistry
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
+from nanobot.utils.file_edit_events import (
+ build_file_edit_end_event,
+ build_file_edit_error_event,
+ build_file_edit_start_event,
+ prepare_file_edit_tracker,
+)
from nanobot.utils.helpers import (
IncrementalThinkExtractor,
build_assistant_message,
@@ -26,6 +32,7 @@ from nanobot.utils.helpers import (
strip_think,
truncate_text,
)
+from nanobot.utils.progress_events import invoke_file_edit_progress
from nanobot.utils.prompt_templates import render_template
from nanobot.utils.runtime import (
EMPTY_FINAL_RESPONSE_MESSAGE,
@@ -813,6 +820,21 @@ class AgentRunner:
return prep_error + hint, event, (
RuntimeError(prep_error) if spec.fail_on_tool_error else None
)
+ file_edit_tracker = prepare_file_edit_tracker(
+ call_id=tool_call.id,
+ tool_name=tool_call.name,
+ tool=tool,
+ workspace=spec.workspace,
+ params=params if isinstance(params, dict) else None,
+ )
+ if file_edit_tracker is not None and spec.progress_callback is not None:
+ await invoke_file_edit_progress(
+ spec.progress_callback,
+ [build_file_edit_start_event(
+ file_edit_tracker,
+ params if isinstance(params, dict) else None,
+ )],
+ )
try:
if tool is not None:
result = await tool.execute(**params)
@@ -821,6 +843,11 @@ class AgentRunner:
except asyncio.CancelledError:
raise
except BaseException as exc:
+ if file_edit_tracker is not None and spec.progress_callback is not None:
+ await invoke_file_edit_progress(
+ spec.progress_callback,
+ [build_file_edit_error_event(file_edit_tracker, str(exc))],
+ )
event = {
"name": tool_call.name,
"status": "error",
@@ -842,6 +869,11 @@ class AgentRunner:
return payload, event, None
if isinstance(result, str) and result.startswith("Error"):
+ if file_edit_tracker is not None and spec.progress_callback is not None:
+ await invoke_file_edit_progress(
+ spec.progress_callback,
+ [build_file_edit_error_event(file_edit_tracker, result)],
+ )
event = {
"name": tool_call.name,
"status": "error",
@@ -860,6 +892,12 @@ class AgentRunner:
return result + hint, event, RuntimeError(result)
return result + hint, event, None
+ if file_edit_tracker is not None and spec.progress_callback is not None:
+ await invoke_file_edit_progress(
+ spec.progress_callback,
+ [build_file_edit_end_event(file_edit_tracker)],
+ )
+
detail = "" if result is None else str(result)
detail = detail.replace("\n", " ").strip()
if not detail:
diff --git a/nanobot/channels/websocket.py b/nanobot/channels/websocket.py
index 86a33c8b..0202bd33 100644
--- a/nanobot/channels/websocket.py
+++ b/nanobot/channels/websocket.py
@@ -1606,6 +1606,7 @@ class WebSocketChannel(BaseChannel):
if not conns:
if (
msg.metadata.get("_progress")
+ or msg.metadata.get("_file_edit_events")
or msg.metadata.get("_turn_end")
or msg.metadata.get("_session_updated")
or msg.metadata.get("_goal_status")
@@ -1638,7 +1639,22 @@ class WebSocketChannel(BaseChannel):
await self.send_turn_end(msg.chat_id, latency_ms=lat_i, goal_state=gs_blob)
return
if msg.metadata.get("_session_updated"):
- await self.send_session_updated(msg.chat_id)
+ scope = msg.metadata.get("_session_update_scope")
+ await self.send_session_updated(
+ msg.chat_id,
+ scope=scope if isinstance(scope, str) else None,
+ )
+ return
+ if msg.metadata.get("_file_edit_events"):
+ payload: dict[str, Any] = {
+ "event": "file_edit",
+ "chat_id": msg.chat_id,
+ "edits": msg.metadata["_file_edit_events"],
+ }
+ self._try_append_webui_transcript(msg.chat_id, payload)
+ raw = json.dumps(payload, ensure_ascii=False)
+ for connection in conns:
+ await self._safe_send_to(connection, raw, label=" ")
return
text = msg.content
payload: dict[str, Any] = {
@@ -1805,12 +1821,14 @@ class WebSocketChannel(BaseChannel):
for connection in conns:
await self._safe_send_to(connection, raw, label=" goal_status ")
- async def send_session_updated(self, chat_id: str) -> None:
+ async def send_session_updated(self, chat_id: str, *, scope: str | None = None) -> None:
"""Notify clients that session metadata changed outside the main turn."""
conns = list(self._subs.get(chat_id, ()))
if not conns:
return
body: dict[str, Any] = {"event": "session_updated", "chat_id": chat_id}
+ if scope:
+ body["scope"] = scope
raw = json.dumps(body, ensure_ascii=False)
for connection in conns:
await self._safe_send_to(connection, raw, label=" session_updated ")
diff --git a/nanobot/utils/file_edit_events.py b/nanobot/utils/file_edit_events.py
new file mode 100644
index 00000000..8164aa18
--- /dev/null
+++ b/nanobot/utils/file_edit_events.py
@@ -0,0 +1,311 @@
+"""File-edit activity helpers for WebUI progress events."""
+
+from __future__ import annotations
+
+import difflib
+import json
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Any
+
+
+TRACKED_FILE_EDIT_TOOLS = frozenset({"write_file", "edit_file", "notebook_edit"})
+_MAX_SNAPSHOT_BYTES = 2 * 1024 * 1024
+
+
+@dataclass(slots=True)
+class FileSnapshot:
+ path: Path
+ exists: bool
+ text: str | None
+ unreadable: bool = False
+ binary: bool = False
+ oversized: bool = False
+
+ @property
+ def countable(self) -> bool:
+ return (
+ self.text is not None
+ and not self.binary
+ and not self.oversized
+ and not self.unreadable
+ )
+
+
+@dataclass(slots=True)
+class FileEditTracker:
+ call_id: str
+ tool: str
+ path: Path
+ display_path: str
+ before: FileSnapshot
+
+
+def is_file_edit_tool(tool_name: str | None) -> bool:
+ return bool(tool_name) and tool_name in TRACKED_FILE_EDIT_TOOLS
+
+
+def resolve_file_edit_path(
+ tool: Any,
+ workspace: Path | None,
+ params: dict[str, Any] | None,
+) -> Path | None:
+ """Resolve the target file path after tool argument preparation."""
+ if not isinstance(params, dict):
+ return None
+ raw_path = params.get("path")
+ if not isinstance(raw_path, str) or not raw_path.strip():
+ return None
+ resolver = getattr(tool, "_resolve", None)
+ if callable(resolver):
+ try:
+ resolved = resolver(raw_path)
+ if isinstance(resolved, Path):
+ return resolved
+ if resolved:
+ return Path(resolved)
+ except Exception:
+ return None
+ if workspace is None:
+ return Path(raw_path).expanduser().resolve()
+ return (workspace / raw_path).expanduser().resolve()
+
+
+def display_file_edit_path(path: Path, workspace: Path | None) -> str:
+ if workspace is not None:
+ try:
+ return path.resolve().relative_to(workspace.resolve()).as_posix()
+ except Exception:
+ pass
+ return path.as_posix()
+
+
+def read_file_snapshot(path: Path, *, max_bytes: int = _MAX_SNAPSHOT_BYTES) -> FileSnapshot:
+ try:
+ if not path.exists() or not path.is_file():
+ return FileSnapshot(path=path, exists=False, text="")
+ size = path.stat().st_size
+ if size > max_bytes:
+ return FileSnapshot(path=path, exists=True, text=None, oversized=True)
+ raw = path.read_bytes()
+ except OSError:
+ return FileSnapshot(path=path, exists=path.exists(), text=None, unreadable=True)
+ if b"\x00" in raw:
+ return FileSnapshot(path=path, exists=True, text=None, binary=True)
+ try:
+ text = raw.decode("utf-8")
+ except UnicodeDecodeError:
+ return FileSnapshot(path=path, exists=True, text=None, binary=True)
+ return FileSnapshot(path=path, exists=True, text=text.replace("\r\n", "\n"))
+
+
+def line_diff_stats(before: str | None, after: str | None) -> tuple[int, int]:
+ """Return ``(added, deleted)`` for a UTF-8 text line-level diff."""
+ if before is None or after is None:
+ return 0, 0
+ before_lines = before.replace("\r\n", "\n").splitlines()
+ after_lines = after.replace("\r\n", "\n").splitlines()
+ added = 0
+ deleted = 0
+ matcher = difflib.SequenceMatcher(a=before_lines, b=after_lines, autojunk=False)
+ for tag, i1, i2, j1, j2 in matcher.get_opcodes():
+ if tag == "equal":
+ continue
+ if tag in ("replace", "delete"):
+ deleted += i2 - i1
+ if tag in ("replace", "insert"):
+ added += j2 - j1
+ return added, deleted
+
+
+def prepare_file_edit_tracker(
+ *,
+ call_id: str,
+ tool_name: str,
+ tool: Any,
+ workspace: Path | None,
+ params: dict[str, Any] | None,
+) -> FileEditTracker | None:
+ if not is_file_edit_tool(tool_name):
+ return None
+ path = resolve_file_edit_path(tool, workspace, params)
+ if path is None:
+ return None
+ before = read_file_snapshot(path)
+ return FileEditTracker(
+ call_id=str(call_id or ""),
+ tool=tool_name,
+ path=path,
+ display_path=display_file_edit_path(path, workspace),
+ before=before,
+ )
+
+
+def build_file_edit_start_event(
+ tracker: FileEditTracker,
+ params: dict[str, Any] | None,
+) -> dict[str, Any]:
+ predicted_after = _predict_after_text(tracker.tool, params or {}, tracker.before)
+ if tracker.before.countable and predicted_after is not None:
+ added, deleted = line_diff_stats(tracker.before.text, predicted_after)
+ else:
+ added, deleted = 0, 0
+ return _event_payload(
+ tracker,
+ phase="start",
+ status="editing",
+ added=added,
+ deleted=deleted,
+ approximate=True,
+ )
+
+
+def build_file_edit_end_event(tracker: FileEditTracker) -> dict[str, Any]:
+ after = read_file_snapshot(tracker.path)
+ if tracker.before.countable and after.countable:
+ added, deleted = line_diff_stats(tracker.before.text, after.text)
+ else:
+ added, deleted = 0, 0
+ return _event_payload(
+ tracker,
+ phase="end",
+ status="done",
+ added=added,
+ deleted=deleted,
+ approximate=False,
+ binary=after.binary or after.oversized or after.unreadable,
+ )
+
+
+def build_file_edit_error_event(tracker: FileEditTracker, error: str | None = None) -> dict[str, Any]:
+ payload = _event_payload(
+ tracker,
+ phase="error",
+ status="error",
+ added=0,
+ deleted=0,
+ approximate=False,
+ )
+ if error:
+ payload["error"] = error.strip()[:240]
+ return payload
+
+
+def _event_payload(
+ tracker: FileEditTracker,
+ *,
+ phase: str,
+ status: str,
+ added: int,
+ deleted: int,
+ approximate: bool,
+ binary: bool = False,
+) -> dict[str, Any]:
+ payload: dict[str, Any] = {
+ "version": 1,
+ "call_id": tracker.call_id,
+ "tool": tracker.tool,
+ "path": tracker.display_path,
+ "phase": phase,
+ "added": max(0, int(added)),
+ "deleted": max(0, int(deleted)),
+ "approximate": bool(approximate),
+ "status": status,
+ }
+ if binary:
+ payload["binary"] = True
+ return payload
+
+
+def _predict_after_text(
+ tool_name: str,
+ params: dict[str, Any],
+ before: FileSnapshot,
+) -> str | None:
+ if not before.countable:
+ return None
+ before_text = before.text or ""
+ if tool_name == "write_file":
+ content = params.get("content")
+ return content if isinstance(content, str) else ""
+ if tool_name == "edit_file":
+ old_text = params.get("old_text")
+ new_text = params.get("new_text")
+ if not isinstance(old_text, str) or not isinstance(new_text, str):
+ return None
+ replace_all = bool(params.get("replace_all"))
+ if old_text == "":
+ return new_text if not before.exists else before_text
+ if old_text in before_text:
+ if replace_all:
+ return before_text.replace(old_text, new_text)
+ return before_text.replace(old_text, new_text, 1)
+ return None
+ if tool_name == "notebook_edit":
+ return _predict_notebook_after_text(params, before_text)
+ return None
+
+
+def _predict_notebook_after_text(params: dict[str, Any], before_text: str) -> str | None:
+ try:
+ nb = json.loads(before_text) if before_text.strip() else _empty_notebook()
+ except Exception:
+ return None
+ cells = nb.get("cells")
+ if not isinstance(cells, list):
+ return None
+ try:
+ cell_index = int(params.get("cell_index", 0))
+ except (TypeError, ValueError):
+ return None
+ new_source = params.get("new_source")
+ source = new_source if isinstance(new_source, str) else ""
+ cell_type = params.get("cell_type") if params.get("cell_type") in ("code", "markdown") else "code"
+ mode = params.get("edit_mode") if params.get("edit_mode") in ("replace", "insert", "delete") else "replace"
+ if mode == "delete":
+ if 0 <= cell_index < len(cells):
+ cells.pop(cell_index)
+ else:
+ return None
+ elif mode == "insert":
+ insert_at = min(max(cell_index + 1, 0), len(cells))
+ cells.insert(insert_at, _new_notebook_cell(source, str(cell_type)))
+ else:
+ if not (0 <= cell_index < len(cells)):
+ return None
+ cell = cells[cell_index]
+ if not isinstance(cell, dict):
+ return None
+ cell["source"] = source
+ cell["cell_type"] = cell_type
+ if cell_type == "code":
+ cell.setdefault("outputs", [])
+ cell.setdefault("execution_count", None)
+ else:
+ cell.pop("outputs", None)
+ cell.pop("execution_count", None)
+ nb["cells"] = cells
+ try:
+ return json.dumps(nb, indent=1, ensure_ascii=False)
+ except Exception:
+ return None
+
+
+def _empty_notebook() -> dict[str, Any]:
+ return {
+ "nbformat": 4,
+ "nbformat_minor": 5,
+ "metadata": {
+ "kernelspec": {"display_name": "Python 3", "language": "python", "name": "python3"},
+ "language_info": {"name": "python"},
+ },
+ "cells": [],
+ }
+
+
+def _new_notebook_cell(source: str, cell_type: str) -> dict[str, Any]:
+ cell: dict[str, Any] = {"cell_type": cell_type, "source": source, "metadata": {}}
+ if cell_type == "code":
+ cell["outputs"] = []
+ cell["execution_count"] = None
+ return cell
diff --git a/nanobot/utils/progress_events.py b/nanobot/utils/progress_events.py
index 10a282b9..ccf125ec 100644
--- a/nanobot/utils/progress_events.py
+++ b/nanobot/utils/progress_events.py
@@ -10,13 +10,21 @@ from nanobot.agent.hook import AgentHookContext
def on_progress_accepts_tool_events(cb: Callable[..., Any]) -> bool:
+ return _on_progress_accepts(cb, "tool_events")
+
+
+def on_progress_accepts_file_edit_events(cb: Callable[..., Any]) -> bool:
+ return _on_progress_accepts(cb, "file_edit_events")
+
+
+def _on_progress_accepts(cb: Callable[..., Any], name: str) -> bool:
try:
sig = inspect.signature(cb)
except (TypeError, ValueError):
return False
if any(p.kind == inspect.Parameter.VAR_KEYWORD for p in sig.parameters.values()):
return True
- return "tool_events" in sig.parameters
+ return name in sig.parameters
async def invoke_on_progress(
@@ -32,6 +40,15 @@ async def invoke_on_progress(
await on_progress(content, tool_hint=tool_hint)
+async def invoke_file_edit_progress(
+ on_progress: Callable[..., Awaitable[None]],
+ file_edit_events: list[dict[str, Any]],
+) -> None:
+ if not file_edit_events or not on_progress_accepts_file_edit_events(on_progress):
+ return
+ await on_progress("", file_edit_events=file_edit_events)
+
+
def build_tool_event_start_payload(tool_call: Any) -> dict[str, Any]:
return {
"version": 1,
diff --git a/nanobot/utils/webui_titles.py b/nanobot/utils/webui_titles.py
deleted file mode 100644
index 2d363f92..00000000
--- a/nanobot/utils/webui_titles.py
+++ /dev/null
@@ -1,138 +0,0 @@
-"""Helpers for WebUI chat title generation."""
-
-from __future__ import annotations
-
-import re
-from typing import Any
-
-from loguru import logger
-
-from nanobot.providers.base import LLMProvider
-from nanobot.session.manager import Session, SessionManager
-from nanobot.utils.helpers import truncate_text
-
-WEBUI_SESSION_METADATA_KEY = "webui"
-WEBUI_TITLE_METADATA_KEY = "title"
-WEBUI_TITLE_USER_EDITED_METADATA_KEY = "title_user_edited"
-TITLE_MAX_CHARS = 60
-
-
-def mark_webui_session(session: Session, metadata: dict[str, Any]) -> bool:
- """Persist a WebUI marker only when the inbound websocket frame opted in."""
- if metadata.get(WEBUI_SESSION_METADATA_KEY) is not True:
- return False
- session.metadata[WEBUI_SESSION_METADATA_KEY] = True
- return True
-
-
-def clean_generated_title(raw: str | None) -> str:
- text = (raw or "").strip()
- if not text:
- return ""
- text = re.sub(r"^\s*(title|标题)\s*[::]\s*", "", text, flags=re.IGNORECASE)
- text = text.strip().strip("\"'`“”‘’")
- text = re.sub(r"\s+", " ", text).strip()
- text = text.rstrip("。.!!??,,;;:")
- if len(text) > TITLE_MAX_CHARS:
- text = text[: TITLE_MAX_CHARS - 1].rstrip() + "…"
- return text
-
-
-def _title_inputs(session: Session) -> tuple[str, str]:
- user_text = ""
- assistant_text = ""
- for message in session.messages:
- role = message.get("role")
- content = message.get("content")
- if not isinstance(content, str) or not content.strip():
- continue
- if role == "user" and not user_text:
- user_text = content.strip()
- elif role == "assistant" and not assistant_text:
- assistant_text = content.strip()
- if user_text and assistant_text:
- break
- return user_text, assistant_text
-
-
-async def maybe_generate_webui_title(
- *,
- sessions: SessionManager,
- session_key: str,
- provider: LLMProvider,
- model: str,
-) -> bool:
- """Generate and persist a short title for WebUI-owned sessions only."""
- session = sessions.get_or_create(session_key)
- if session.metadata.get(WEBUI_SESSION_METADATA_KEY) is not True:
- return False
- if session.metadata.get(WEBUI_TITLE_USER_EDITED_METADATA_KEY) is True:
- return False
- current_title = session.metadata.get(WEBUI_TITLE_METADATA_KEY)
- if isinstance(current_title, str) and current_title.strip():
- return False
-
- user_text, assistant_text = _title_inputs(session)
- if not user_text:
- return False
-
- prompt = (
- "Generate a concise title for this chat.\n"
- "Rules:\n"
- "- Use the same language as the user when practical.\n"
- "- 3 to 8 words.\n"
- "- No quotes.\n"
- "- No punctuation at the end.\n"
- "- Return only the title.\n\n"
- f"User: {truncate_text(user_text, 1_000)}"
- )
- if assistant_text:
- prompt += f"\nAssistant: {truncate_text(assistant_text, 1_000)}"
-
- try:
- response = await provider.chat_with_retry(
- [
- {
- "role": "system",
- "content": (
- "You write short, neutral chat titles. "
- "Return only the title text."
- ),
- },
- {"role": "user", "content": prompt},
- ],
- tools=None,
- model=model,
- max_tokens=32,
- temperature=0.2,
- retry_mode="standard",
- )
- except Exception:
- logger.debug("Failed to generate webui session title for {}", session_key, exc_info=True)
- return False
-
- title = clean_generated_title(response.content)
- if not title or title.lower().startswith("error"):
- return False
- session.metadata[WEBUI_TITLE_METADATA_KEY] = title
- sessions.save(session)
- return True
-
-
-async def maybe_generate_webui_title_after_turn(
- *,
- channel: str,
- metadata: dict[str, Any],
- sessions: SessionManager,
- session_key: str,
- provider: LLMProvider,
- model: str,
-) -> bool:
- if channel != "websocket" or metadata.get(WEBUI_SESSION_METADATA_KEY) is not True:
- return False
- return await maybe_generate_webui_title(
- sessions=sessions,
- session_key=session_key,
- provider=provider,
- model=model,
- )
diff --git a/nanobot/utils/webui_transcript.py b/nanobot/utils/webui_transcript.py
index dde0e916..bee71c54 100644
--- a/nanobot/utils/webui_transcript.py
+++ b/nanobot/utils/webui_transcript.py
@@ -125,11 +125,25 @@ def replay_transcript_to_ui_messages(
buffer_message_id: str | None = None
buffer_parts: list[str] = []
suppress_until_turn_end = False
+ active_activity_segment_id: str | None = None
+ active_file_edit_segment_id: str | None = None
+ activity_segment_counter = 0
_ts_base = int(time.time() * 1000)
def _new_id(prefix: str, idx: int) -> str:
return f"{prefix}-{idx}-{uuid.uuid4().hex[:8]}"
+ def _new_activity_segment(*, activate: bool = True) -> str:
+ nonlocal active_activity_segment_id, activity_segment_counter
+ activity_segment_counter += 1
+ segment_id = f"activity-{activity_segment_counter}"
+ if activate:
+ active_activity_segment_id = segment_id
+ return segment_id
+
+ def _ensure_activity_segment() -> str:
+ return active_activity_segment_id or _new_activity_segment()
+
def attach_reasoning_chunk(prev: list[dict[str, Any]], chunk: str, idx: int) -> None:
for i in range(len(prev) - 1, -1, -1):
candidate = prev[i]
@@ -151,12 +165,19 @@ def replay_transcript_to_ui_messages(
**candidate,
"reasoning": (str(candidate.get("reasoning") or "")) + chunk,
"reasoningStreaming": True,
+ "activitySegmentId": candidate.get("activitySegmentId") or _ensure_activity_segment(),
}
return
if not has_answer and candidate.get("isStreaming"):
- prev[i] = {**candidate, "reasoning": chunk, "reasoningStreaming": True}
+ prev[i] = {
+ **candidate,
+ "reasoning": chunk,
+ "reasoningStreaming": True,
+ "activitySegmentId": candidate.get("activitySegmentId") or _ensure_activity_segment(),
+ }
return
break
+ segment = _ensure_activity_segment()
prev.append(
{
"id": _new_id("as", idx),
@@ -165,6 +186,7 @@ def replay_transcript_to_ui_messages(
"isStreaming": True,
"reasoning": chunk,
"reasoningStreaming": True,
+ "activitySegmentId": segment,
"createdAt": _ts_base + idx,
},
)
@@ -221,6 +243,7 @@ def replay_transcript_to_ui_messages(
return
def absorb_complete(extra: dict[str, Any], idx: int) -> None:
+ nonlocal active_activity_segment_id
last = messages[-1] if messages else None
if last and is_reasoning_only_placeholder(last):
messages[-1] = {
@@ -238,10 +261,76 @@ def replay_transcript_to_ui_messages(
**extra,
},
)
+ active_activity_segment_id = None
+
+ def _file_edit_key(edit: dict[str, Any]) -> str:
+ return "|".join(
+ str(edit.get(k) or "")
+ for k in ("call_id", "tool", "path")
+ )
+
+ def upsert_file_edits(edits: list[dict[str, Any]], idx: int) -> None:
+ nonlocal active_file_edit_segment_id
+ if not edits:
+ return
+ last = messages[-1] if messages else None
+ if (
+ active_file_edit_segment_id
+ and last
+ and last.get("kind") == "trace"
+ and last.get("fileEdits")
+ ):
+ segment = active_file_edit_segment_id
+ else:
+ segment = _new_activity_segment(activate=False)
+ active_file_edit_segment_id = segment
+ if not (
+ last
+ and last.get("kind") == "trace"
+ and not last.get("isStreaming")
+ and last.get("fileEdits")
+ and last.get("activitySegmentId") == segment
+ ):
+ messages.append(
+ {
+ "id": _new_id("tr", idx),
+ "role": "tool",
+ "kind": "trace",
+ "content": "",
+ "traces": [],
+ "fileEdits": [],
+ "activitySegmentId": segment,
+ "createdAt": _ts_base + idx,
+ },
+ )
+ last = messages[-1]
+ existing = list(last.get("fileEdits") or [])
+ index_by_key = {
+ _file_edit_key(edit): pos
+ for pos, edit in enumerate(existing)
+ if isinstance(edit, dict)
+ }
+ for edit in edits:
+ if not isinstance(edit, dict):
+ continue
+ key = _file_edit_key(edit)
+ if key in index_by_key:
+ pos = index_by_key[key]
+ existing[pos] = {**existing[pos], **edit}
+ else:
+ index_by_key[key] = len(existing)
+ existing.append(dict(edit))
+ messages[-1] = {
+ **last,
+ "fileEdits": existing,
+ "activitySegmentId": last.get("activitySegmentId") or segment,
+ }
for idx, rec in enumerate(lines):
ev = rec.get("event")
if ev == "user":
+ active_activity_segment_id = None
+ active_file_edit_segment_id = None
text = rec.get("text")
text_s = text if isinstance(text, str) else ""
media_paths = rec.get("media_paths")
@@ -264,6 +353,12 @@ def replay_transcript_to_ui_messages(
messages.append(row)
continue
+ if ev == "file_edit":
+ raw_edits = rec.get("edits")
+ if isinstance(raw_edits, list):
+ upsert_file_edits([e for e in raw_edits if isinstance(e, dict)], idx)
+ continue
+
if ev == "delta":
if suppress_until_turn_end:
continue
@@ -338,14 +433,21 @@ def replay_transcript_to_ui_messages(
trace_lines = structured if structured else ([text] if isinstance(text, str) and text else [])
if not trace_lines:
continue
+ segment = _ensure_activity_segment()
last = messages[-1] if messages else None
- if last and last.get("kind") == "trace" and not last.get("isStreaming"):
+ if (
+ last
+ and last.get("kind") == "trace"
+ and not last.get("isStreaming")
+ and (last.get("activitySegmentId") in (None, segment))
+ ):
prev_traces = list(last.get("traces") or [last.get("content")])
merged_traces = prev_traces + trace_lines
messages[-1] = {
**last,
"traces": merged_traces,
"content": trace_lines[-1],
+ "activitySegmentId": last.get("activitySegmentId") or segment,
}
else:
messages.append(
@@ -355,6 +457,7 @@ def replay_transcript_to_ui_messages(
"kind": "trace",
"content": trace_lines[-1],
"traces": trace_lines,
+ "activitySegmentId": segment,
"createdAt": _ts_base + idx,
},
)
@@ -389,6 +492,8 @@ def replay_transcript_to_ui_messages(
if ev == "turn_end":
suppress_until_turn_end = False
+ active_activity_segment_id = None
+ active_file_edit_segment_id = None
for i, m in enumerate(messages):
if m.get("isStreaming"):
messages[i] = {**m, "isStreaming": False}
diff --git a/nanobot/utils/webui_turn_helpers.py b/nanobot/utils/webui_turn_helpers.py
index 3fbca372..10403852 100644
--- a/nanobot/utils/webui_turn_helpers.py
+++ b/nanobot/utils/webui_turn_helpers.py
@@ -6,15 +6,161 @@ AgentLoop uses these without importing a concrete channel plugin; only
from __future__ import annotations
+import re
import time
+from collections.abc import Awaitable, Callable
+from dataclasses import dataclass, field
from typing import Any
+from loguru import logger
+
from nanobot.bus.events import InboundMessage, OutboundMessage
from nanobot.bus.queue import MessageBus
+from nanobot.providers.base import LLMProvider
+from nanobot.session.goal_state import goal_state_ws_blob
+from nanobot.session.manager import Session, SessionManager
+from nanobot.utils.helpers import truncate_text
+
+WEBUI_SESSION_METADATA_KEY = "webui"
+WEBUI_TITLE_METADATA_KEY = "title"
+WEBUI_TITLE_USER_EDITED_METADATA_KEY = "title_user_edited"
+TITLE_MAX_CHARS = 60
+TITLE_GENERATION_MAX_TOKENS = 96
+TITLE_GENERATION_REASONING_EFFORT = "none"
# Wall-clock turn start per ``chat_id`` (websocket only). Survives browser refresh while the
# gateway process stays up; cleared on idle/stop and implicitly dropped on restart.
_WEBSOCKET_TURN_WALL_STARTED_AT: dict[str, float] = {}
+TitleContext = tuple[LLMProvider, str]
+
+
+def mark_webui_session(session: Session, metadata: dict[str, Any]) -> bool:
+ """Persist a WebUI marker only when the inbound websocket frame opted in."""
+ if metadata.get(WEBUI_SESSION_METADATA_KEY) is not True:
+ return False
+ session.metadata[WEBUI_SESSION_METADATA_KEY] = True
+ return True
+
+
+def clean_generated_title(raw: str | None) -> str:
+ text = (raw or "").strip()
+ if not text:
+ return ""
+ text = re.sub(r"^\s*(title|标题)\s*[::]\s*", "", text, flags=re.IGNORECASE)
+ text = text.strip().strip("\"'`“”‘’")
+ text = re.sub(r"\s+", " ", text).strip()
+ text = text.rstrip("。.!!??,,;;:")
+ if len(text) > TITLE_MAX_CHARS:
+ text = text[: TITLE_MAX_CHARS - 1].rstrip() + "…"
+ return text
+
+
+def _title_inputs(session: Session) -> tuple[str, str]:
+ user_text = ""
+ assistant_text = ""
+ for message in session.messages:
+ if message.get("_command") is True:
+ continue
+ role = message.get("role")
+ content = message.get("content")
+ if not isinstance(content, str) or not content.strip():
+ continue
+ if role == "user" and not user_text:
+ user_text = content.strip()
+ elif role == "assistant" and not assistant_text:
+ assistant_text = content.strip()
+ if user_text and assistant_text:
+ break
+ return user_text, assistant_text
+
+
+async def maybe_generate_webui_title(
+ *,
+ sessions: SessionManager,
+ session_key: str,
+ provider: LLMProvider,
+ model: str,
+) -> bool:
+ """Generate and persist a short title for WebUI-owned sessions only."""
+ session = sessions.get_or_create(session_key)
+ if session.metadata.get(WEBUI_SESSION_METADATA_KEY) is not True:
+ return False
+ if session.metadata.get(WEBUI_TITLE_USER_EDITED_METADATA_KEY) is True:
+ return False
+ current_title = session.metadata.get(WEBUI_TITLE_METADATA_KEY)
+ if isinstance(current_title, str) and current_title.strip():
+ return False
+
+ user_text, assistant_text = _title_inputs(session)
+ if not user_text:
+ return False
+
+ prompt = (
+ "Generate a concise title for this chat.\n"
+ "Rules:\n"
+ "- Use the same language as the user when practical.\n"
+ "- 3 to 8 words.\n"
+ "- No quotes.\n"
+ "- No punctuation at the end.\n"
+ "- Return only the title.\n\n"
+ f"User: {truncate_text(user_text, 1_000)}"
+ )
+ if assistant_text:
+ prompt += f"\nAssistant: {truncate_text(assistant_text, 1_000)}"
+
+ try:
+ response = await provider.chat_with_retry(
+ [
+ {
+ "role": "system",
+ "content": (
+ "You write short, neutral chat titles. "
+ "Return only the title text."
+ ),
+ },
+ {"role": "user", "content": prompt},
+ ],
+ tools=None,
+ model=model,
+ max_tokens=TITLE_GENERATION_MAX_TOKENS,
+ temperature=0.2,
+ reasoning_effort=TITLE_GENERATION_REASONING_EFFORT,
+ retry_mode="standard",
+ )
+ except Exception:
+ logger.debug("Failed to generate webui session title for {}", session_key, exc_info=True)
+ return False
+
+ title = clean_generated_title(response.content)
+ if not title or title.lower().startswith("error"):
+ logger.debug(
+ "WebUI title generation returned no usable title for {} (finish_reason={})",
+ session_key,
+ response.finish_reason,
+ )
+ return False
+ session.metadata[WEBUI_TITLE_METADATA_KEY] = title
+ sessions.save(session)
+ return True
+
+
+async def maybe_generate_webui_title_after_turn(
+ *,
+ channel: str,
+ metadata: dict[str, Any],
+ sessions: SessionManager,
+ session_key: str,
+ provider: LLMProvider,
+ model: str,
+) -> bool:
+ if channel != "websocket" or metadata.get(WEBUI_SESSION_METADATA_KEY) is not True:
+ return False
+ return await maybe_generate_webui_title(
+ sessions=sessions,
+ session_key=session_key,
+ provider=provider,
+ model=model,
+ )
def websocket_turn_wall_started_at(chat_id: str) -> float | None:
@@ -46,3 +192,125 @@ async def publish_turn_run_status(bus: MessageBus, msg: InboundMessage, status:
metadata=meta,
),
)
+
+
+def build_bus_progress_callback(
+ bus: MessageBus,
+ msg: InboundMessage,
+) -> Callable[..., Awaitable[None]]:
+ """Return the bus progress callback for agent runtime events."""
+
+ async def _bus_progress(
+ content: str,
+ *,
+ tool_hint: bool = False,
+ tool_events: list[dict[str, Any]] | None = None,
+ file_edit_events: list[dict[str, Any]] | None = None,
+ reasoning: bool = False,
+ reasoning_end: bool = False,
+ ) -> None:
+ if file_edit_events and msg.channel != "websocket":
+ return
+ meta = dict(msg.metadata or {})
+ meta["_progress"] = True
+ meta["_tool_hint"] = tool_hint
+ if reasoning:
+ meta["_reasoning_delta"] = True
+ if reasoning_end:
+ meta["_reasoning_end"] = True
+ if tool_events:
+ meta["_tool_events"] = tool_events
+ if file_edit_events:
+ meta["_file_edit_events"] = file_edit_events
+ await bus.publish_outbound(
+ OutboundMessage(
+ channel=msg.channel,
+ chat_id=msg.chat_id,
+ content=content,
+ metadata=meta,
+ )
+ )
+
+ return _bus_progress
+
+
+@dataclass
+class WebuiTurnCoordinator:
+ """Own the WebUI/WebSocket wire details that hang off AgentLoop turns."""
+
+ bus: MessageBus
+ sessions: SessionManager
+ schedule_background: Callable[[Awaitable[None]], None]
+ _title_contexts: dict[str, TitleContext] = field(default_factory=dict)
+
+ def capture_title_context(
+ self,
+ session_key: str,
+ msg: InboundMessage,
+ provider: LLMProvider,
+ model: str,
+ ) -> None:
+ if msg.channel == "websocket" and msg.metadata.get("webui") is True:
+ self._title_contexts[session_key] = (provider, model)
+
+ def discard(self, session_key: str) -> None:
+ self._title_contexts.pop(session_key, None)
+
+ async def publish_run_status(self, msg: InboundMessage, status: str) -> None:
+ await publish_turn_run_status(self.bus, msg, status)
+
+ async def handle_turn_end(
+ self,
+ msg: InboundMessage,
+ *,
+ session_key: str,
+ latency_ms: int | None,
+ ) -> None:
+ if msg.channel != "websocket":
+ return
+
+ turn_metadata: dict[str, Any] = {**msg.metadata, "_turn_end": True}
+ if latency_ms is not None:
+ turn_metadata["latency_ms"] = int(latency_ms)
+ session = self.sessions.get_or_create(session_key)
+ turn_metadata["goal_state"] = goal_state_ws_blob(session.metadata)
+ await self.bus.publish_outbound(OutboundMessage(
+ channel=msg.channel,
+ chat_id=msg.chat_id,
+ content="",
+ metadata=turn_metadata,
+ ))
+ self._schedule_title_update(msg, session_key=session_key)
+
+ def _schedule_title_update(self, msg: InboundMessage, *, session_key: str) -> None:
+ title_context = self._title_contexts.pop(session_key, None)
+ if msg.metadata.get("webui") is not True or title_context is None:
+ return
+
+ title_provider, title_model = title_context
+
+ async def _generate_title_and_notify(
+ provider: LLMProvider = title_provider,
+ model: str = title_model,
+ ) -> None:
+ generated = await maybe_generate_webui_title_after_turn(
+ channel=msg.channel,
+ metadata=msg.metadata,
+ sessions=self.sessions,
+ session_key=session_key,
+ provider=provider,
+ model=model,
+ )
+ if generated:
+ await self.bus.publish_outbound(OutboundMessage(
+ channel=msg.channel,
+ chat_id=msg.chat_id,
+ content="",
+ metadata={
+ **msg.metadata,
+ "_session_updated": True,
+ "_session_update_scope": "metadata",
+ },
+ ))
+
+ self.schedule_background(_generate_title_and_notify())
diff --git a/tests/agent/test_loop_progress.py b/tests/agent/test_loop_progress.py
index fcf6198c..b1b33612 100644
--- a/tests/agent/test_loop_progress.py
+++ b/tests/agent/test_loop_progress.py
@@ -82,6 +82,96 @@ class TestToolEventProgress:
),
]
+ @pytest.mark.asyncio
+ async def test_write_file_emits_file_edit_progress(self, tmp_path: Path) -> None:
+ loop = _make_loop(tmp_path)
+ target = tmp_path / "foo.txt"
+ target.write_text("old\n", encoding="utf-8")
+ tool_call = ToolCallRequest(
+ id="call-write",
+ name="write_file",
+ arguments={"path": "foo.txt", "content": "new\nextra\n"},
+ )
+ calls = iter([
+ LLMResponse(content="", tool_calls=[tool_call]),
+ LLMResponse(content="Done", tool_calls=[]),
+ ])
+ loop.provider.chat_with_retry = AsyncMock(side_effect=lambda *a, **kw: next(calls))
+ loop.tools.get_definitions = MagicMock(return_value=[])
+ loop.tools.prepare_call = MagicMock(
+ return_value=(None, {"path": "foo.txt", "content": "new\nextra\n"}, None),
+ )
+
+ async def execute(name: str, params: dict) -> str:
+ target.write_text(params["content"], encoding="utf-8")
+ return "ok"
+
+ loop.tools.execute = AsyncMock(side_effect=execute)
+ file_events: list[dict] = []
+
+ async def on_progress(
+ content: str,
+ *,
+ tool_hint: bool = False,
+ tool_events: list[dict] | None = None,
+ file_edit_events: list[dict] | None = None,
+ ) -> None:
+ if file_edit_events:
+ file_events.extend(file_edit_events)
+
+ final_content, _, _, _, _ = await loop._run_agent_loop([], on_progress=on_progress)
+
+ assert final_content == "Done"
+ assert [event["phase"] for event in file_events] == ["start", "end"]
+ assert file_events[0] == {
+ "version": 1,
+ "call_id": "call-write",
+ "tool": "write_file",
+ "path": "foo.txt",
+ "phase": "start",
+ "added": 2,
+ "deleted": 1,
+ "approximate": True,
+ "status": "editing",
+ }
+ assert file_events[1]["status"] == "done"
+ assert file_events[1]["approximate"] is False
+ assert (file_events[1]["added"], file_events[1]["deleted"]) == (2, 1)
+
+ @pytest.mark.asyncio
+ async def test_exec_does_not_emit_file_edit_progress(self, tmp_path: Path) -> None:
+ loop = _make_loop(tmp_path)
+ tool_call = ToolCallRequest(
+ id="call-exec",
+ name="exec",
+ arguments={"command": "printf hi > foo.txt"},
+ )
+ calls = iter([
+ LLMResponse(content="", tool_calls=[tool_call]),
+ LLMResponse(content="Done", tool_calls=[]),
+ ])
+ loop.provider.chat_with_retry = AsyncMock(side_effect=lambda *a, **kw: next(calls))
+ loop.tools.get_definitions = MagicMock(return_value=[])
+ loop.tools.prepare_call = MagicMock(
+ return_value=(None, {"command": "printf hi > foo.txt"}, None),
+ )
+ loop.tools.execute = AsyncMock(return_value="ok")
+ file_events: list[dict] = []
+
+ async def on_progress(
+ content: str,
+ *,
+ tool_hint: bool = False,
+ tool_events: list[dict] | None = None,
+ file_edit_events: list[dict] | None = None,
+ ) -> None:
+ if file_edit_events:
+ file_events.extend(file_edit_events)
+
+ await loop._run_agent_loop([], on_progress=on_progress)
+
+ assert file_events == []
+
@pytest.mark.asyncio
async def test_bus_progress_forwards_tool_events_to_outbound_metadata(self, tmp_path: Path) -> None:
"""When run() handles a bus message, _tool_events lands in OutboundMessage metadata."""
@@ -130,6 +220,42 @@ class TestToolEventProgress:
assert finish["phase"] == "end"
assert finish["result"] == "file.txt"
+ @pytest.mark.asyncio
+ async def test_bus_progress_forwards_file_edit_events_for_websocket_only(self, tmp_path: Path) -> None:
+ bus = MessageBus()
+ provider = MagicMock()
+ provider.get_default_model.return_value = "test-model"
+ loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
+ edit_events = [{
+ "call_id": "call-write",
+ "tool": "write_file",
+ "path": "foo.txt",
+ "phase": "start",
+ "added": 1,
+ "deleted": 0,
+ "approximate": True,
+ "status": "editing",
+ }]
+
+ websocket_progress = await loop._build_bus_progress_callback(InboundMessage(
+ channel="websocket",
+ sender_id="u1",
+ chat_id="chat1",
+ content="edit",
+ ))
+ await websocket_progress("", file_edit_events=edit_events)
+ outbound = await bus.consume_outbound()
+ assert outbound.metadata["_file_edit_events"] == edit_events
+
+ telegram_progress = await loop._build_bus_progress_callback(InboundMessage(
+ channel="telegram",
+ sender_id="u1",
+ chat_id="chat2",
+ content="edit",
+ ))
+ await telegram_progress("", file_edit_events=edit_events)
+ assert bus.outbound_size == 0
+
@pytest.mark.asyncio
async def test_non_streaming_channel_does_not_publish_codex_progress_deltas(
self,
@@ -353,8 +479,93 @@ class TestToolEventProgress:
assert session_updated is not None
assert (session_updated.metadata or {}).get("_session_updated") is True
+ assert (session_updated.metadata or {}).get("_session_update_scope") == "metadata"
assert provider.chat_with_retry.await_count == 2
+ @pytest.mark.asyncio
+ async def test_webui_title_generation_uses_turn_model_snapshot(
+ self,
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ bus = MessageBus()
+ provider = MagicMock()
+ provider.get_default_model.return_value = "test-model"
+ provider.chat_with_retry = AsyncMock(return_value=LLMResponse(content="Done", tool_calls=[]))
+ loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
+ loop.tools.get_definitions = MagicMock(return_value=[])
+ loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
+
+ captured: dict[str, object] = {}
+
+ async def fake_title_after_turn(**kwargs: object) -> bool:
+ captured.update(kwargs)
+ return False
+
+ monkeypatch.setattr(
+ "nanobot.utils.webui_turn_helpers.maybe_generate_webui_title_after_turn",
+ fake_title_after_turn,
+ )
+ scheduled_title: list[object] = []
+
+ def schedule_background(coro: object) -> None:
+ name = getattr(coro, "__qualname__", "")
+ if "_generate_title_and_notify" in name:
+ scheduled_title.append(coro)
+ elif hasattr(coro, "close"):
+ coro.close()
+
+ loop._schedule_background = schedule_background # type: ignore[method-assign]
+
+ await loop._dispatch(InboundMessage(
+ channel="websocket",
+ sender_id="u1",
+ chat_id="chat1",
+ content="say hello",
+ metadata={"webui": True},
+ ))
+
+ assert len(scheduled_title) == 1
+ loop.provider = MagicMock()
+ loop.model = "switched-after-turn"
+
+ await scheduled_title[0] # type: ignore[misc]
+
+ assert captured["provider"] is provider
+ assert captured["model"] == "test-model"
+
+ @pytest.mark.asyncio
+ async def test_webui_command_turn_does_not_schedule_title_generation(
+ self,
+ tmp_path: Path,
+ monkeypatch: pytest.MonkeyPatch,
+ ) -> None:
+ bus = MessageBus()
+ provider = MagicMock()
+ provider.get_default_model.return_value = "test-model"
+ provider.chat_with_retry = AsyncMock(return_value=LLMResponse(content="Done", tool_calls=[]))
+ loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
+
+ async def fake_title_after_turn(**_kwargs: object) -> bool:
+ raise AssertionError("command-only turns should not generate titles")
+
+ monkeypatch.setattr(
+ "nanobot.utils.webui_turn_helpers.maybe_generate_webui_title_after_turn",
+ fake_title_after_turn,
+ )
+ scheduled: list[object] = []
+ loop._schedule_background = scheduled.append # type: ignore[method-assign]
+
+ await loop._dispatch(InboundMessage(
+ channel="websocket",
+ sender_id="u1",
+ chat_id="chat1",
+ content="/model",
+ metadata={"webui": True},
+ ))
+
+ assert scheduled == []
+
@pytest.mark.asyncio
async def test_non_websocket_dispatch_does_not_publish_turn_end_marker(self, tmp_path: Path) -> None:
bus = MessageBus()
diff --git a/tests/agent/test_loop_save_turn.py b/tests/agent/test_loop_save_turn.py
index ed78e719..10529134 100644
--- a/tests/agent/test_loop_save_turn.py
+++ b/tests/agent/test_loop_save_turn.py
@@ -11,7 +11,9 @@ from nanobot.bus.queue import MessageBus
from nanobot.providers.base import LLMResponse
from nanobot.session.goal_state import GOAL_STATE_KEY
from nanobot.session.manager import Session
-from nanobot.utils.webui_titles import (
+from nanobot.utils.webui_turn_helpers import (
+ TITLE_GENERATION_MAX_TOKENS,
+ TITLE_GENERATION_REASONING_EFFORT,
WEBUI_SESSION_METADATA_KEY,
WEBUI_TITLE_METADATA_KEY,
maybe_generate_webui_title,
@@ -55,6 +57,11 @@ async def test_generate_webui_title_only_for_marked_webui_sessions(tmp_path: Pat
assert generated is True
assert session.metadata[WEBUI_TITLE_METADATA_KEY] == "优化 WebUI 侧边栏"
loop.provider.chat_with_retry.assert_awaited_once()
+ assert loop.provider.chat_with_retry.await_args.kwargs["max_tokens"] == TITLE_GENERATION_MAX_TOKENS
+ assert (
+ loop.provider.chat_with_retry.await_args.kwargs["reasoning_effort"]
+ == TITLE_GENERATION_REASONING_EFFORT
+ )
@pytest.mark.asyncio
@@ -79,6 +86,31 @@ async def test_generate_webui_title_skips_plain_websocket_sessions(tmp_path: Pat
loop.provider.chat_with_retry.assert_not_awaited()
+@pytest.mark.asyncio
+async def test_generate_webui_title_ignores_command_only_sessions(tmp_path: Path) -> None:
+ loop = _make_full_loop(tmp_path)
+ session = loop.sessions.get_or_create("websocket:command-title")
+ session.metadata[WEBUI_SESSION_METADATA_KEY] = True
+ session.add_message("user", "/model deep", _command=True)
+ session.add_message(
+ "assistant",
+ "Switched model preset to `deep`.\n- Model: `deepseek-v4-pro`",
+ _command=True,
+ )
+ loop.sessions.save(session)
+
+ generated = await maybe_generate_webui_title(
+ sessions=loop.sessions,
+ session_key="websocket:command-title",
+ provider=loop.provider,
+ model=loop.model,
+ )
+
+ assert generated is False
+ assert WEBUI_TITLE_METADATA_KEY not in session.metadata
+ loop.provider.chat_with_retry.assert_not_awaited()
+
+
def test_save_turn_skips_multimodal_user_when_only_runtime_context() -> None:
loop = _mk_loop()
session = Session(key="test:runtime-only")
diff --git a/tests/channels/test_websocket_channel.py b/tests/channels/test_websocket_channel.py
index 2fa7285f..c6f9d66a 100644
--- a/tests/channels/test_websocket_channel.py
+++ b/tests/channels/test_websocket_channel.py
@@ -370,6 +370,55 @@ async def test_send_progress_includes_structured_tool_events() -> None:
]
+@pytest.mark.asyncio
+async def test_send_file_edit_progress_uses_file_edit_event() -> None:
+ bus = MagicMock()
+ channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
+ mock_ws = AsyncMock()
+ channel._attach(mock_ws, "chat-1")
+
+ await channel.send(OutboundMessage(
+ channel="websocket",
+ chat_id="chat-1",
+ content="",
+ metadata={
+ "_progress": True,
+ "_file_edit_events": [
+ {
+ "version": 1,
+ "phase": "start",
+ "call_id": "call-1",
+ "tool": "write_file",
+ "path": "src/app.py",
+ "added": 12,
+ "deleted": 2,
+ "approximate": True,
+ "status": "editing",
+ }
+ ],
+ },
+ ))
+
+ payload = json.loads(mock_ws.send.await_args.args[0])
+ assert payload == {
+ "event": "file_edit",
+ "chat_id": "chat-1",
+ "edits": [
+ {
+ "version": 1,
+ "phase": "start",
+ "call_id": "call-1",
+ "tool": "write_file",
+ "path": "src/app.py",
+ "added": 12,
+ "deleted": 2,
+ "approximate": True,
+ "status": "editing",
+ }
+ ],
+ }
+
+
@pytest.mark.asyncio
async def test_send_progress_includes_agent_ui_blob() -> None:
bus = MagicMock()
@@ -758,6 +807,25 @@ async def test_send_session_updated_emits_session_updated_event() -> None:
assert body == {"event": "session_updated", "chat_id": "chat-1"}
+@pytest.mark.asyncio
+async def test_send_session_updated_includes_scope_when_present() -> None:
+ bus = MagicMock()
+ channel = WebSocketChannel({"enabled": True, "allowFrom": ["*"]}, bus)
+ mock_ws = AsyncMock()
+ channel._attach(mock_ws, "chat-1")
+
+ await channel.send(OutboundMessage(
+ channel="websocket",
+ chat_id="chat-1",
+ content="",
+ metadata={"_session_updated": True, "_session_update_scope": "metadata"},
+ ))
+
+ mock_ws.send.assert_awaited_once()
+ body = json.loads(mock_ws.send.await_args.args[0])
+ assert body == {"event": "session_updated", "chat_id": "chat-1", "scope": "metadata"}
+
+
@pytest.mark.asyncio
async def test_send_non_connection_closed_exception_is_raised() -> None:
bus = MagicMock()
diff --git a/tests/utils/test_file_edit_events.py b/tests/utils/test_file_edit_events.py
new file mode 100644
index 00000000..6176a5e3
--- /dev/null
+++ b/tests/utils/test_file_edit_events.py
@@ -0,0 +1,83 @@
+from __future__ import annotations
+
+from pathlib import Path
+
+from nanobot.utils.file_edit_events import (
+ build_file_edit_end_event,
+ build_file_edit_start_event,
+ line_diff_stats,
+ prepare_file_edit_tracker,
+ read_file_snapshot,
+)
+
+
+def test_line_diff_stats_counts_replacements_insertions_and_deletions() -> None:
+ added, deleted = line_diff_stats("a\nb\nc\n", "a\nB\nc\nd\n")
+ assert (added, deleted) == (2, 1)
+
+
+def test_line_diff_stats_normalizes_crlf() -> None:
+ assert line_diff_stats("a\r\nb\r\n", "a\nb\nc\n") == (1, 0)
+
+
+def test_write_file_start_predicts_and_end_calibrates_exact_diff(tmp_path: Path) -> None:
+ target = tmp_path / "notes.txt"
+ target.write_text("old\nkeep\n", encoding="utf-8")
+ params = {"path": "notes.txt", "content": "new\nkeep\nextra\n"}
+ tracker = prepare_file_edit_tracker(
+ call_id="call-write",
+ tool_name="write_file",
+ tool=None,
+ workspace=tmp_path,
+ params=params,
+ )
+
+ assert tracker is not None
+ start = build_file_edit_start_event(tracker, params)
+ assert start == {
+ "version": 1,
+ "call_id": "call-write",
+ "tool": "write_file",
+ "path": "notes.txt",
+ "phase": "start",
+ "added": 2,
+ "deleted": 1,
+ "approximate": True,
+ "status": "editing",
+ }
+
+ target.write_text("new\nkeep\nextra\n", encoding="utf-8")
+ end = build_file_edit_end_event(tracker)
+ assert end["phase"] == "end"
+ assert end["status"] == "done"
+ assert end["approximate"] is False
+ assert (end["added"], end["deleted"]) == (2, 1)
+
+
+def test_binary_file_is_reported_but_not_counted(tmp_path: Path) -> None:
+ target = tmp_path / "data.bin"
+ target.write_bytes(b"\x00\x01before")
+ tracker = prepare_file_edit_tracker(
+ call_id="call-bin",
+ tool_name="edit_file",
+ tool=None,
+ workspace=tmp_path,
+ params={"path": "data.bin", "old_text": "before", "new_text": "after"},
+ )
+
+ assert tracker is not None
+ assert not read_file_snapshot(target).countable
+ target.write_bytes(b"\x00\x01after")
+ event = build_file_edit_end_event(tracker)
+ assert event["binary"] is True
+ assert (event["added"], event["deleted"]) == (0, 0)
+
+
+def test_untracked_tools_do_not_prepare_file_edit_tracker(tmp_path: Path) -> None:
+ assert prepare_file_edit_tracker(
+ call_id="call-exec",
+ tool_name="exec",
+ tool=None,
+ workspace=tmp_path,
+ params={"path": "created-by-shell.txt"},
+ ) is None
diff --git a/tests/utils/test_webui_transcript.py b/tests/utils/test_webui_transcript.py
index 419abbfc..f13380f4 100644
--- a/tests/utils/test_webui_transcript.py
+++ b/tests/utils/test_webui_transcript.py
@@ -42,6 +42,62 @@ def test_replay_delta_and_turn_end(tmp_path, monkeypatch) -> None:
assert msgs[1]["latencyMs"] == 42
+def test_replay_file_edit_event_creates_file_activity(tmp_path, monkeypatch) -> None:
+ monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
+ key = "websocket:t-file"
+ for ev in (
+ {"event": "user", "chat_id": "t-file", "text": "edit"},
+ {
+ "event": "message",
+ "chat_id": "t-file",
+ "text": 'write_file({"path":"foo.txt"})',
+ "kind": "tool_hint",
+ },
+ {
+ "event": "file_edit",
+ "chat_id": "t-file",
+ "edits": [
+ {
+ "version": 1,
+ "call_id": "call-write",
+ "tool": "write_file",
+ "path": "foo.txt",
+ "phase": "end",
+ "added": 2,
+ "deleted": 1,
+ "approximate": False,
+ "status": "done",
+ },
+ ],
+ },
+ ):
+ append_transcript_object(key, ev)
+
+ msgs = replay_transcript_to_ui_messages(read_transcript_lines(key))
+
+ assert len(msgs) == 3
+ assert msgs[1]["kind"] == "trace"
+ assert msgs[1]["traces"] == ['write_file({"path":"foo.txt"})']
+ assert "fileEdits" not in msgs[1]
+ assert msgs[2]["kind"] == "trace"
+ assert msgs[2]["traces"] == []
+ assert msgs[2]["fileEdits"] == [
+ {
+ "version": 1,
+ "call_id": "call-write",
+ "tool": "write_file",
+ "path": "foo.txt",
+ "phase": "end",
+ "added": 2,
+ "deleted": 1,
+ "approximate": False,
+ "status": "done",
+ },
+ ]
+ assert msgs[2]["activitySegmentId"]
+ assert msgs[2]["activitySegmentId"] != msgs[1]["activitySegmentId"]
+
+
def test_build_response_schema(monkeypatch, tmp_path) -> None:
from nanobot.utils.webui_transcript import build_webui_thread_response
From 945f208d382c929f6988e358563771562061e294 Mon Sep 17 00:00:00 2001
From: Xubin Ren <52506698+Re-bin@users.noreply.github.com>
Date: Sun, 17 May 2026 23:52:14 +0800
Subject: [PATCH 05/12] feat(webui): render file edit activity
---
webui/src/components/FileReferenceChip.tsx | 220 +++++++++++
.../thread/AgentActivityCluster.tsx | 359 +++++++++++++++++-
.../src/components/thread/ThreadMessages.tsx | 69 +++-
webui/src/hooks/useNanobotStream.ts | 220 +++++++++--
webui/src/lib/types.ts | 25 +-
.../src/tests/agent-activity-cluster.test.tsx | 134 ++++++-
webui/src/tests/thread-messages.test.tsx | 147 +++++++
webui/src/tests/useNanobotStream.test.tsx | 167 ++++++++
8 files changed, 1292 insertions(+), 49 deletions(-)
create mode 100644 webui/src/components/FileReferenceChip.tsx
diff --git a/webui/src/components/FileReferenceChip.tsx b/webui/src/components/FileReferenceChip.tsx
new file mode 100644
index 00000000..18e63d1c
--- /dev/null
+++ b/webui/src/components/FileReferenceChip.tsx
@@ -0,0 +1,220 @@
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { cn } from "@/lib/utils";
+
+type FileReferenceKind =
+ | "default"
+ | "css"
+ | "html"
+ | "json"
+ | "markdown"
+ | "notebook"
+ | "python"
+ | "react"
+ | "typescript";
+
+interface FileReferenceChipProps {
+ path: string;
+ display?: "name" | "path";
+ active?: boolean;
+ className?: string;
+ textClassName?: string;
+ testId?: string;
+}
+
+export function FileReferenceChip({
+ path,
+ display = "name",
+ active = false,
+ className,
+ textClassName,
+ testId = "inline-file-path",
+}: FileReferenceChipProps) {
+ const { name } = splitFilePath(path);
+ const kind = fileKindForPath(path);
+ const displayText = display === "path" ? path.replace(/\\/g, "/") : name;
+ return (
+
+
+
+
+
+
+
+ {displayText}
+
+
+
+
+
+ {path}
+
+
+
+ );
+}
+
+export function isLikelyFilePath(value: string): boolean {
+ const raw = value.trim();
+ if (!raw || raw.includes("\n")) return false;
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(raw)) return false;
+ if (!/[\\/]/.test(raw) && !/^(dockerfile|makefile|readme|package-lock\.json)$/i.test(raw)) {
+ return false;
+ }
+ const normalized = raw.replace(/\\/g, "/");
+ const name = normalized.split("/").filter(Boolean).pop() ?? normalized;
+ if (!name || name === "." || name === "..") return false;
+ if (/^(dockerfile|makefile|readme|package-lock\.json)$/i.test(name)) return true;
+ return /\.[a-z0-9][a-z0-9_-]{0,12}$/i.test(name);
+}
+
+function splitFilePath(path: string): { directory: string; name: string } {
+ const normalized = path.replace(/\\/g, "/");
+ const slash = normalized.lastIndexOf("/");
+ if (slash < 0) return { directory: "", name: path };
+ return {
+ directory: normalized.slice(0, slash + 1),
+ name: normalized.slice(slash + 1) || normalized,
+ };
+}
+
+function fileKindForPath(path: string): FileReferenceKind {
+ const normalized = path.toLowerCase();
+ const name = normalized.split(/[\\/]/).pop() ?? normalized;
+ const ext = name.includes(".") ? name.split(".").pop() ?? "" : "";
+ if (name === "dockerfile") {
+ return "default";
+ }
+ switch (ext) {
+ case "py":
+ case "pyi":
+ return "python";
+ case "jsx":
+ case "tsx":
+ return "react";
+ case "ts":
+ return "typescript";
+ case "html":
+ case "htm":
+ return "html";
+ case "css":
+ case "scss":
+ case "sass":
+ return "css";
+ case "json":
+ case "jsonl":
+ return "json";
+ case "md":
+ case "mdx":
+ return "markdown";
+ case "ipynb":
+ return "notebook";
+ default:
+ return "default";
+ }
+}
+
+function FileReferenceIcon({ kind }: { kind: FileReferenceKind }) {
+ if (kind === "react") {
+ return (
+
+ );
+ }
+ if (kind === "default") {
+ return (
+
+ );
+ }
+ const label = fileKindLabel(kind);
+ return (
+
+ {label}
+
+ );
+}
+
+function fileKindLabel(kind: FileReferenceKind): string {
+ switch (kind) {
+ case "css":
+ return "#";
+ case "html":
+ return "H";
+ case "json":
+ return "{}";
+ case "markdown":
+ return "M";
+ case "notebook":
+ return "N";
+ case "python":
+ return "PY";
+ case "typescript":
+ return "TS";
+ default:
+ return "";
+ }
+}
diff --git a/webui/src/components/thread/AgentActivityCluster.tsx b/webui/src/components/thread/AgentActivityCluster.tsx
index a29f590a..792a4156 100644
--- a/webui/src/components/thread/AgentActivityCluster.tsx
+++ b/webui/src/components/thread/AgentActivityCluster.tsx
@@ -1,10 +1,11 @@
-import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
-import { ChevronRight, Layers } from "lucide-react";
+import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
+import { AlertCircle, ChevronRight, Layers } from "lucide-react";
import { useTranslation } from "react-i18next";
+import { FileReferenceChip } from "@/components/FileReferenceChip";
import { ReasoningBubble, StreamingLabelSheen, TraceGroup } from "@/components/MessageBubble";
import { cn } from "@/lib/utils";
-import type { UIMessage } from "@/lib/types";
+import type { UIFileEdit, UIMessage } from "@/lib/types";
/** Scrollport height for the Cursor-style “live trace” strip (tailwind spacing). */
const CLUSTER_SCROLL_MAX_CLASS = "max-h-52";
@@ -20,7 +21,29 @@ export function isAgentActivityMember(m: UIMessage): boolean {
return isReasoningOnlyAssistant(m) || m.kind === "trace";
}
-function countActivity(messages: UIMessage[]): { reasoningSteps: number; toolCalls: number } {
+interface ActivityCounts {
+ reasoningSteps: number;
+ toolCalls: number;
+ fileCount: number;
+ added: number;
+ deleted: number;
+ hasEditingFiles: boolean;
+ hasFailedFiles: boolean;
+ primaryFilePath?: string;
+}
+
+interface FileEditSummary {
+ key: string;
+ path: string;
+ added: number;
+ deleted: number;
+ approximate: boolean;
+ binary: boolean;
+ status: UIFileEdit["status"];
+ error?: string;
+}
+
+function countActivity(messages: UIMessage[], fileEdits: FileEditSummary[]): ActivityCounts {
let reasoningSteps = 0;
let toolCalls = 0;
for (const m of messages) {
@@ -30,10 +53,38 @@ function countActivity(messages: UIMessage[]): { reasoningSteps: number; toolCal
}
if (m.kind === "trace") {
const lines = m.traces?.length ?? (m.content.trim() ? 1 : 0);
- toolCalls += Math.max(lines, 1);
+ toolCalls += lines;
}
}
- return { reasoningSteps, toolCalls };
+ let added = 0;
+ let deleted = 0;
+ let hasEditingFiles = false;
+ let failedFileCount = 0;
+ let primaryFilePath: string | undefined;
+ for (const edit of fileEdits) {
+ primaryFilePath = edit.path;
+ if (edit.status === "editing") {
+ hasEditingFiles = true;
+ }
+ if (edit.status === "error") {
+ failedFileCount += 1;
+ }
+ if (edit.status === "error" || edit.binary) {
+ continue;
+ }
+ added += edit.added;
+ deleted += edit.deleted;
+ }
+ return {
+ reasoningSteps,
+ toolCalls,
+ fileCount: fileEdits.length,
+ added,
+ deleted,
+ hasEditingFiles,
+ hasFailedFiles: fileEdits.length > 0 && failedFileCount === fileEdits.length,
+ primaryFilePath,
+ };
}
interface AgentActivityClusterProps {
@@ -53,7 +104,20 @@ export function AgentActivityCluster({
hasBodyBelow,
}: AgentActivityClusterProps) {
const { t } = useTranslation();
- const { reasoningSteps, toolCalls } = countActivity(messages);
+ const fileEdits = useMemo(
+ () => summarizeFileEdits(collectFileEdits(messages), isTurnStreaming),
+ [messages, isTurnStreaming],
+ );
+ const {
+ reasoningSteps,
+ toolCalls,
+ fileCount,
+ added,
+ deleted,
+ hasEditingFiles,
+ hasFailedFiles,
+ primaryFilePath,
+ } = countActivity(messages, fileEdits);
const [userToggledOuter, setUserToggledOuter] = useState(false);
const [outerOpenLocal, setOuterOpenLocal] = useState(false);
@@ -64,16 +128,32 @@ export function AgentActivityCluster({
/** Collapsed by default during “Working…” and after the turn; user expands to inspect traces. */
const outerExpanded = userToggledOuter ? outerOpenLocal : false;
- const headerBusy = isTurnStreaming;
+ const hasLiveEditingFiles = isTurnStreaming && hasEditingFiles;
+ const headerBusy = fileCount > 0 ? hasEditingFiles : isTurnStreaming;
- const summary =
- isTurnStreaming
+ const fileActivitySummary = fileCount > 0
+ ? fileCount === 1 && primaryFilePath
+ ? t(fileActivitySummaryKey(hasLiveEditingFiles, hasFailedFiles), {
+ file: shortFileName(primaryFilePath),
+ defaultValue: `${fileActivityVerb(hasLiveEditingFiles, hasFailedFiles)} {{file}}`,
+ })
+ : t(fileActivityManySummaryKey(hasLiveEditingFiles, hasFailedFiles), {
+ count: fileCount,
+ defaultValue: `${fileActivityVerb(hasLiveEditingFiles, hasFailedFiles)} {{count}} files`,
+ })
+ : "";
+
+ const summary = fileCount > 0
+ ? fileActivitySummary
+ : isTurnStreaming
? reasoningSteps > 0
? t("message.agentActivityLiveSummary", {
reasoning: reasoningSteps,
tools: toolCalls,
defaultValue: "Working… · {{reasoning}} steps · {{tools}} tool calls",
})
+ : toolCalls === 0 && fileCount > 0
+ ? t("message.agentActivityLiveFilesOnly", { defaultValue: "Working…" })
: t("message.agentActivityLiveToolsOnly", {
tools: toolCalls,
defaultValue: "Working… · {{tools}} tool calls",
@@ -84,6 +164,8 @@ export function AgentActivityCluster({
tools: toolCalls,
defaultValue: "{{reasoning}} steps · {{tools}} tool calls",
})
+ : toolCalls === 0 && fileCount > 0
+ ? t("message.agentActivityFilesOnly", { defaultValue: "File changes" })
: t("message.agentActivityToolsOnly", {
tools: toolCalls,
defaultValue: "{{tools}} tool calls",
@@ -161,12 +243,19 @@ export function AgentActivityCluster({
aria-expanded={outerExpanded}
>
-
- {summary}
-
+
+
+ {summary}
+
+ {fileCount > 0 && (
+
+
+
+ )}
+
);
}
if (m.kind === "trace") {
- return ;
+ const hasTraceLines = (m.traces?.length ?? 0) > 0 || m.content.trim().length > 0;
+ return hasTraceLines ? (
+
+
+
+ ) : null;
}
return null;
})}
+ {fileEdits.length ? : null}
@@ -216,3 +311,231 @@ export function AgentActivityCluster({
);
}
+
+function shortFileName(path: string): string {
+ return path.split(/[\\/]/).pop() || path;
+}
+
+function fileActivityVerb(editing: boolean, failed: boolean): string {
+ if (failed) return "Failed";
+ return editing ? "Editing" : "Edited";
+}
+
+function fileActivitySummaryKey(editing: boolean, failed: boolean): string {
+ if (failed) return "message.fileActivityFailedOne";
+ return editing ? "message.fileActivityEditingOne" : "message.fileActivityEditedOne";
+}
+
+function fileActivityManySummaryKey(editing: boolean, failed: boolean): string {
+ if (failed) return "message.fileActivityFailedMany";
+ return editing ? "message.fileActivityEditingMany" : "message.fileActivityEditedMany";
+}
+
+function fileEditCallKey(edit: UIFileEdit): string {
+ return `${edit.call_id}|${edit.tool}|${edit.path}`;
+}
+
+function collectFileEdits(messages: UIMessage[]): UIFileEdit[] {
+ const edits: UIFileEdit[] = [];
+ for (const message of messages) {
+ if (message.kind === "trace" && message.fileEdits?.length) {
+ edits.push(...message.fileEdits);
+ }
+ }
+ return edits;
+}
+
+function latestFileEditEvents(edits: UIFileEdit[]): UIFileEdit[] {
+ const order: string[] = [];
+ const byKey = new Map();
+ for (const edit of edits) {
+ const key = fileEditCallKey(edit);
+ if (!byKey.has(key)) order.push(key);
+ byKey.set(key, edit);
+ }
+ return order.map((key) => byKey.get(key)).filter(Boolean) as UIFileEdit[];
+}
+
+function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSummary[] {
+ interface MutableSummary {
+ key: string;
+ path: string;
+ added: number;
+ deleted: number;
+ approximate: boolean;
+ binary: boolean;
+ hasSuccessfulChange: boolean;
+ hasActiveEditing: boolean;
+ hasFailed: boolean;
+ error?: string;
+ }
+
+ const order: string[] = [];
+ const byPath = new Map();
+ for (const edit of latestFileEditEvents(edits)) {
+ const key = edit.path;
+ let summary = byPath.get(key);
+ if (!summary) {
+ summary = {
+ key,
+ path: edit.path,
+ added: 0,
+ deleted: 0,
+ approximate: false,
+ binary: false,
+ hasSuccessfulChange: false,
+ hasActiveEditing: false,
+ hasFailed: false,
+ };
+ byPath.set(key, summary);
+ order.push(key);
+ }
+
+ if (active && edit.status === "editing") {
+ summary.hasActiveEditing = true;
+ summary.binary = summary.binary || !!edit.binary;
+ summary.approximate = summary.approximate || !!edit.approximate;
+ if (!edit.binary) {
+ summary.added += edit.added;
+ summary.deleted += edit.deleted;
+ }
+ continue;
+ }
+
+ if (edit.status === "error") {
+ summary.hasFailed = true;
+ summary.error = edit.error ?? summary.error;
+ continue;
+ }
+
+ summary.hasSuccessfulChange = true;
+ summary.binary = summary.binary || !!edit.binary;
+ summary.approximate = active && (summary.approximate || !!edit.approximate);
+ if (!edit.binary) {
+ summary.added += edit.added;
+ summary.deleted += edit.deleted;
+ }
+ }
+
+ return order.map((key) => {
+ const summary = byPath.get(key)!;
+ const status: UIFileEdit["status"] = summary.hasActiveEditing
+ ? "editing"
+ : summary.hasSuccessfulChange
+ ? "done"
+ : summary.hasFailed
+ ? "error"
+ : "done";
+ return {
+ key: summary.key,
+ path: summary.path,
+ added: summary.added,
+ deleted: summary.deleted,
+ approximate: summary.approximate,
+ binary: summary.binary,
+ status,
+ error: summary.error,
+ };
+ });
+}
+
+function FileEditGroup({ edits }: { edits: FileEditSummary[] }) {
+ if (edits.length === 0) return null;
+ return (
+
+ {edits.map((edit) => (
+
+ ))}
+
+ );
+}
+
+function FileEditRow({ edit }: { edit: FileEditSummary }) {
+ const { t } = useTranslation();
+ const editing = edit.status === "editing";
+ const failed = edit.status === "error";
+ const hasCountedDiff = !failed && !edit.binary;
+ return (
+
+
+
+ {failed ? (
+
+
+ {t("message.fileEditFailed", { defaultValue: "Failed" })}
+
+ ) : null}
+ {edit.approximate && !failed ? (
+
+ {t("message.fileEditApproximate", { defaultValue: "estimated" })}
+
+ ) : null}
+
+ {hasCountedDiff ? (
+
+ ) : null}
+
+ );
+}
+
+function DiffPair({ added, deleted }: { added: number; deleted: number }) {
+ return (
+
+
+ +
+
+
+ -
+
+
+ );
+}
+
+function AnimatedNumber({ value }: { value: number }) {
+ const safeValue = Number.isFinite(value) ? Math.max(0, Math.round(value)) : 0;
+ const [display, setDisplay] = useState(0);
+ const displayRef = useRef(0);
+
+ const setAnimatedDisplay = useCallback((next: number) => {
+ displayRef.current = next;
+ setDisplay(next);
+ }, []);
+
+ useEffect(() => {
+ const reduceMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
+ if (reduceMotion) {
+ setAnimatedDisplay(safeValue);
+ return;
+ }
+ const start = displayRef.current;
+ const delta = safeValue - start;
+ if (delta === 0) {
+ setAnimatedDisplay(safeValue);
+ return;
+ }
+ const duration = 260;
+ const startedAt = performance.now();
+ let frame = 0;
+ const tick = (now: number) => {
+ const progress = Math.min(1, (now - startedAt) / duration);
+ const eased = 1 - Math.pow(1 - progress, 3);
+ setAnimatedDisplay(Math.round(start + delta * eased));
+ if (progress < 1) {
+ frame = window.requestAnimationFrame(tick);
+ return;
+ }
+ displayRef.current = safeValue;
+ };
+ frame = window.requestAnimationFrame(tick);
+ return () => window.cancelAnimationFrame(frame);
+ }, [safeValue, setAnimatedDisplay]);
+
+ return <>{display}>;
+}
diff --git a/webui/src/components/thread/ThreadMessages.tsx b/webui/src/components/thread/ThreadMessages.tsx
index 30817121..869d282f 100644
--- a/webui/src/components/thread/ThreadMessages.tsx
+++ b/webui/src/components/thread/ThreadMessages.tsx
@@ -42,26 +42,77 @@ export function buildDisplayUnits(messages: UIMessage[]): DisplayUnit[] {
const m = messages[i];
if (isAgentActivityMember(m)) {
const cluster: UIMessage[] = [];
- while (i < messages.length && isAgentActivityMember(messages[i])) {
- cluster.push(messages[i]);
+ let segmentId: string | undefined = m.activitySegmentId;
+ let clusterHasFileEdits = hasFileEdits(m);
+ while (
+ i < messages.length
+ && isAgentActivityMember(messages[i])
+ && canJoinActivityCluster(segmentId, clusterHasFileEdits, messages[i])
+ ) {
+ const current = messages[i];
+ if (!segmentId && current.activitySegmentId) {
+ segmentId = current.activitySegmentId;
+ }
+ clusterHasFileEdits = clusterHasFileEdits || hasFileEdits(current);
+ cluster.push(current);
i += 1;
}
out.push({ type: "cluster", messages: cluster });
continue;
}
const previous = out[out.length - 1];
- if (previous?.type === "cluster" && assistantHasInlineReasoning(m)) {
+ if (
+ previous?.type === "cluster"
+ && assistantHasInlineReasoning(m)
+ && canFoldInlineReasoning(previous.messages, m)
+ ) {
previous.messages.push(reasoningOnlyMessageFromAnswer(m));
out.push({ type: "single", message: stripInlineReasoning(m) });
i += 1;
continue;
}
+ if (assistantHasInlineReasoning(m)) {
+ out.push({ type: "cluster", messages: [reasoningOnlyMessageFromAnswer(m)] });
+ out.push({ type: "single", message: stripInlineReasoning(m) });
+ i += 1;
+ continue;
+ }
out.push({ type: "single", message: m });
i += 1;
}
return out;
}
+function clusterSegmentId(messages: UIMessage[]): string | undefined {
+ return messages.find((message) => message.activitySegmentId)?.activitySegmentId;
+}
+
+function hasFileEdits(message: UIMessage): boolean {
+ return !!message.fileEdits?.length;
+}
+
+function clusterHasFileEdits(messages: UIMessage[]): boolean {
+ return messages.some(hasFileEdits);
+}
+
+function canJoinActivityCluster(
+ clusterSegmentId: string | undefined,
+ clusterIncludesFileEdits: boolean,
+ message: UIMessage,
+): boolean {
+ const messageHasFileEdits = hasFileEdits(message);
+ if (!clusterIncludesFileEdits && !messageHasFileEdits) return true;
+ if (!clusterSegmentId || !message.activitySegmentId) return true;
+ return clusterSegmentId === message.activitySegmentId;
+}
+
+function canFoldInlineReasoning(cluster: UIMessage[], message: UIMessage): boolean {
+ if (!clusterHasFileEdits(cluster) && !hasFileEdits(message)) return true;
+ const segmentId = clusterSegmentId(cluster);
+ if (!segmentId || !message.activitySegmentId) return true;
+ return segmentId === message.activitySegmentId;
+}
+
function assistantHasInlineReasoning(message: UIMessage): boolean {
return (
message.role === "assistant"
@@ -80,6 +131,7 @@ function reasoningOnlyMessageFromAnswer(message: UIMessage): UIMessage {
reasoning: message.reasoning,
reasoningStreaming: message.reasoningStreaming,
isStreaming: message.reasoningStreaming,
+ activitySegmentId: message.activitySegmentId,
};
}
@@ -116,6 +168,10 @@ export function ThreadMessages({
const { t } = useTranslation();
const units = useMemo(() => buildDisplayUnits(messages), [messages]);
const copyFlags = useMemo(() => assistantCopyFlags(units), [units]);
+ const liveActivityClusterIndex = useMemo(
+ () => isStreaming ? currentActivityClusterIndex(units) : -1,
+ [isStreaming, units],
+ );
return (
@@ -150,7 +206,7 @@ export function ThreadMessages({
{unit.type === "cluster" ? (
) : (
@@ -170,6 +226,11 @@ export function ThreadMessages({
);
}
+function currentActivityClusterIndex(units: DisplayUnit[]): number {
+ const last = units.length - 1;
+ return units[last]?.type === "cluster" ? last : -1;
+}
+
function unitKey(unit: DisplayUnit, index: number): string {
if (unit.type === "cluster") {
const anchor = unit.messages[0]?.id;
diff --git a/webui/src/hooks/useNanobotStream.ts b/webui/src/hooks/useNanobotStream.ts
index 9ea03602..2ee11322 100644
--- a/webui/src/hooks/useNanobotStream.ts
+++ b/webui/src/hooks/useNanobotStream.ts
@@ -10,6 +10,7 @@ import type {
OutboundMedia,
GoalStateWsPayload,
UIImage,
+ UIFileEdit,
UIMessage,
} from "@/lib/types";
@@ -27,12 +28,17 @@ type PendingStreamEvent =
| { kind: "delta"; text: string }
| { kind: "reasoning"; text: string };
-/** Scan upward from the bottom skipping trace rows so tool breadcrumbs don't steal the stream target. */
-function findStreamingAssistantIndex(prev: UIMessage[]): number | null {
+/** Find a still-open streamed assistant turn. Closed stream segments stay visible
+ * as streaming until ``turn_end`` for visual continuity, but they must not
+ * receive later delta segments. */
+function findStreamingAssistantIndex(
+ prev: UIMessage[],
+ closedStreamIds: ReadonlySet
,
+): number | null {
for (let i = prev.length - 1; i >= 0; i -= 1) {
const m = prev[i];
if (m.kind === "trace") continue;
- if (m.role === "assistant" && m.isStreaming) return i;
+ if (m.role === "assistant" && m.isStreaming && !closedStreamIds.has(m.id)) return i;
if (m.role === "user") break;
}
return null;
@@ -47,7 +53,13 @@ function findStreamingAssistantIndex(prev: UIMessage[]): number | null {
* case the reasoning still belongs to the same assistant turn and must render
* above the answer, not as a new row below it.
*/
-function attachReasoningChunk(prev: UIMessage[], chunk: string): UIMessage[] {
+function attachReasoningChunk(
+ prev: UIMessage[],
+ chunk: string,
+ segments?: {
+ ensure: () => string;
+ },
+): UIMessage[] {
for (let i = prev.length - 1; i >= 0; i -= 1) {
const candidate = prev[i];
// A user turn is a hard boundary: reasoning after it belongs to the new
@@ -58,6 +70,7 @@ function attachReasoningChunk(prev: UIMessage[], chunk: string): UIMessage[] {
// that produced those tool calls.
if (candidate.kind === "trace") break;
if (candidate.role !== "assistant") continue;
+ const activitySegmentId = candidate.activitySegmentId ?? segments?.ensure();
const hasAnswer = candidate.content.length > 0;
if (
candidate.reasoningStreaming
@@ -69,6 +82,7 @@ function attachReasoningChunk(prev: UIMessage[], chunk: string): UIMessage[] {
...candidate,
reasoning: (candidate.reasoning ?? "") + chunk,
reasoningStreaming: true,
+ ...(activitySegmentId ? { activitySegmentId } : {}),
};
return [...prev.slice(0, i), merged, ...prev.slice(i + 1)];
}
@@ -77,11 +91,13 @@ function attachReasoningChunk(prev: UIMessage[], chunk: string): UIMessage[] {
...candidate,
reasoning: chunk,
reasoningStreaming: true,
+ ...(activitySegmentId ? { activitySegmentId } : {}),
};
return [...prev.slice(0, i), merged, ...prev.slice(i + 1)];
}
break;
}
+ const activitySegmentId = segments?.ensure();
return [
...prev,
{
@@ -91,6 +107,7 @@ function attachReasoningChunk(prev: UIMessage[], chunk: string): UIMessage[] {
isStreaming: true,
reasoning: chunk,
reasoningStreaming: true,
+ ...(activitySegmentId ? { activitySegmentId } : {}),
createdAt: Date.now(),
},
];
@@ -197,6 +214,47 @@ function absorbCompleteAssistantMessage(
];
}
+function fileEditKey(edit: Pick): string {
+ return `${edit.call_id}|${edit.tool}|${edit.path}`;
+}
+
+function normalizeFileEdit(edit: UIFileEdit): UIFileEdit | null {
+ if (!edit || !edit.path || !edit.tool) return null;
+ const inferredStatus =
+ edit.phase === "error"
+ ? "error"
+ : edit.phase === "end"
+ ? "done"
+ : "editing";
+ return {
+ ...edit,
+ call_id: edit.call_id || `${edit.tool}:${edit.path}`,
+ added: Number.isFinite(edit.added) ? Math.max(0, Math.round(edit.added)) : 0,
+ deleted: Number.isFinite(edit.deleted) ? Math.max(0, Math.round(edit.deleted)) : 0,
+ status: edit.status === "error" || edit.status === "done" || edit.status === "editing"
+ ? edit.status
+ : inferredStatus,
+ };
+}
+
+function mergeFileEdits(existing: UIFileEdit[] | undefined, incoming: UIFileEdit[]): UIFileEdit[] {
+ const next = [...(existing ?? [])];
+ const indexByKey = new Map(next.map((edit, index) => [fileEditKey(edit), index]));
+ for (const raw of incoming) {
+ const edit = normalizeFileEdit(raw);
+ if (!edit) continue;
+ const key = fileEditKey(edit);
+ const existingIndex = indexByKey.get(key);
+ if (existingIndex === undefined) {
+ indexByKey.set(key, next.length);
+ next.push(edit);
+ continue;
+ }
+ next[existingIndex] = { ...next[existingIndex], ...edit };
+ }
+ return next;
+}
+
/**
* Subscribe to a chat by ID. Returns the in-memory message list for the chat,
* a streaming flag, and a ``send`` function. Initial history must be seeded
@@ -255,6 +313,10 @@ export function useNanobotStream(
const [streamError, setStreamError] = useState(null);
const buffer = useRef(null);
const activeAssistantRef = useRef(null);
+ const closedAssistantStreamIdsRef = useRef>(new Set());
+ const activitySegmentRef = useRef(null);
+ const fileEditSegmentRef = useRef(null);
+ const activitySegmentCounterRef = useRef(0);
const pendingStreamEventsRef = useRef([]);
const streamFrameRef = useRef(null);
const suppressStreamUntilTurnEndRef = useRef(false);
@@ -281,6 +343,40 @@ export function useNanobotStream(
pendingStreamEventsRef.current = [];
}, []);
+ const createActivitySegmentId = useCallback((activate = true) => {
+ activitySegmentCounterRef.current += 1;
+ const id = `activity-${activitySegmentCounterRef.current}`;
+ if (activate) activitySegmentRef.current = id;
+ return id;
+ }, []);
+
+ const freshActivitySegmentId = useCallback(
+ () => createActivitySegmentId(true),
+ [createActivitySegmentId],
+ );
+
+ const detachedActivitySegmentId = useCallback(
+ () => createActivitySegmentId(false),
+ [createActivitySegmentId],
+ );
+
+ const ensureActivitySegmentId = useCallback(() => {
+ if (activitySegmentRef.current) return activitySegmentRef.current;
+ return freshActivitySegmentId();
+ }, [freshActivitySegmentId]);
+
+ const clearActivitySegment = useCallback(() => {
+ activitySegmentRef.current = null;
+ fileEditSegmentRef.current = null;
+ }, []);
+
+ const closeActiveAssistantStream = useCallback(() => {
+ const closedStreamId = buffer.current?.messageId ?? activeAssistantRef.current?.id;
+ if (closedStreamId) closedAssistantStreamIdsRef.current.add(closedStreamId);
+ buffer.current = null;
+ activeAssistantRef.current = null;
+ }, []);
+
const resolveActiveAssistantIndex = useCallback((prev: UIMessage[]): number | null => {
const cursor = activeAssistantRef.current;
if (!cursor) return null;
@@ -311,7 +407,7 @@ export function useNanobotStream(
targetIndex = findActiveAssistantPlaceholderIndex(next);
}
if (targetIndex === null) {
- targetIndex = findStreamingAssistantIndex(next);
+ targetIndex = findStreamingAssistantIndex(next, closedAssistantStreamIdsRef.current);
}
if (targetIndex === null) {
const id = crypto.randomUUID();
@@ -334,6 +430,7 @@ export function useNanobotStream(
content: target.content + chunk,
isStreaming: true,
};
+ closedAssistantStreamIdsRef.current.delete(merged.id);
activeAssistantRef.current = { id: merged.id, index: targetIndex };
buffer.current = { messageId: merged.id };
return replaceMessageAt(next, targetIndex, merged);
@@ -353,23 +450,32 @@ export function useNanobotStream(
}
next = kind === "delta"
? appendAnswerChunk(next, text)
- : attachReasoningChunk(next, text);
+ : attachReasoningChunk(next, text, {
+ ensure: ensureActivitySegmentId,
+ });
}
return next;
},
- [appendAnswerChunk],
+ [appendAnswerChunk, ensureActivitySegmentId],
);
- const flushPendingStreamEvents = useCallback(() => {
+ const flushPendingStreamEvents = useCallback((options?: { closeAnswerSegment?: boolean }) => {
if (streamFrameRef.current !== null) {
window.cancelAnimationFrame(streamFrameRef.current);
streamFrameRef.current = null;
}
const events = pendingStreamEventsRef.current;
- if (events.length === 0) return;
+ if (events.length === 0) {
+ if (options?.closeAnswerSegment) closeActiveAssistantStream();
+ return;
+ }
pendingStreamEventsRef.current = [];
- setMessages((prev) => applyPendingStreamEvents(prev, events));
- }, [applyPendingStreamEvents]);
+ setMessages((prev) => {
+ const next = applyPendingStreamEvents(prev, events);
+ if (options?.closeAnswerSegment) closeActiveAssistantStream();
+ return next;
+ });
+ }, [applyPendingStreamEvents, closeActiveAssistantStream]);
const schedulePendingStreamFlush = useCallback(() => {
if (streamFrameRef.current !== null) return;
@@ -397,6 +503,8 @@ export function useNanobotStream(
setGoalState(chatId ? client.getGoalState(chatId) : undefined);
buffer.current = null;
activeAssistantRef.current = null;
+ closedAssistantStreamIdsRef.current.clear();
+ clearActivitySegment();
clearPendingStreamWork();
suppressStreamUntilTurnEndRef.current = false;
if (streamEndTimerRef.current !== null) {
@@ -404,7 +512,7 @@ export function useNanobotStream(
streamEndTimerRef.current = null;
}
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [chatId, client, clearPendingStreamWork]);
+ }, [chatId, client, clearActivitySegment, clearPendingStreamWork]);
useEffect(() => {
if (hasPendingToolCalls) setIsStreaming(true);
@@ -442,21 +550,17 @@ export function useNanobotStream(
return;
}
- flushPendingStreamEvents();
-
if (ev.event === "stream_end") {
- if (suppressStreamUntilTurnEndRef.current) {
- buffer.current = null;
- return;
- }
+ flushPendingStreamEvents({ closeAnswerSegment: true });
+ if (suppressStreamUntilTurnEndRef.current) return;
// stream_end only means the text segment finished — the model may
// still be executing tools. Do NOT reset isStreaming here; the
// definitive "turn is complete" signal is ``turn_end``.
- if (!buffer.current) return;
- buffer.current = null;
return;
}
+ flushPendingStreamEvents();
+
if (ev.event === "reasoning_end") {
if (suppressStreamUntilTurnEndRef.current) return;
setMessages((prev) => closeReasoningStream(prev));
@@ -496,6 +600,8 @@ export function useNanobotStream(
}
buffer.current = null;
activeAssistantRef.current = null;
+ clearActivitySegment();
+ closedAssistantStreamIdsRef.current.clear();
return finalized;
});
suppressStreamUntilTurnEndRef.current = false;
@@ -516,7 +622,9 @@ export function useNanobotStream(
if (ev.kind === "reasoning") {
const line = ev.text;
if (!line) return;
- setMessages((prev) => closeReasoningStream(attachReasoningChunk(prev, line)));
+ setMessages((prev) => closeReasoningStream(attachReasoningChunk(prev, line, {
+ ensure: ensureActivitySegmentId,
+ })));
return;
}
// Intermediate agent breadcrumbs (tool-call hints, raw progress).
@@ -531,12 +639,24 @@ export function useNanobotStream(
: [];
if (lines.length === 0) return;
setMessages((prev) => {
+ const segmentId = ensureActivitySegmentId();
const last = prev[prev.length - 1];
- if (last && last.kind === "trace" && !last.isStreaming) {
+ if (
+ last
+ && last.kind === "trace"
+ && !last.isStreaming
+ && (!last.activitySegmentId || last.activitySegmentId === segmentId)
+ ) {
+ const previousTraces = last.traces?.length
+ ? last.traces
+ : last.content
+ ? [last.content]
+ : [];
const merged: UIMessage = {
...last,
- traces: [...(last.traces ?? [last.content]), ...lines],
+ traces: [...previousTraces, ...lines],
content: lines[lines.length - 1],
+ activitySegmentId: last.activitySegmentId ?? segmentId,
};
return [...prev.slice(0, -1), merged];
}
@@ -548,6 +668,7 @@ export function useNanobotStream(
kind: "trace",
content: lines[lines.length - 1],
traces: lines,
+ activitySegmentId: segmentId,
createdAt: Date.now(),
},
];
@@ -585,6 +706,46 @@ export function useNanobotStream(
}
return;
}
+ if (ev.event === "file_edit") {
+ const edits = Array.isArray(ev.edits) ? ev.edits : [];
+ if (edits.length === 0) return;
+ setMessages((prev) => {
+ const last = prev[prev.length - 1];
+ let segmentId = fileEditSegmentRef.current;
+ if (!segmentId || !(last?.kind === "trace" && last.fileEdits?.length)) {
+ segmentId = detachedActivitySegmentId();
+ fileEditSegmentRef.current = segmentId;
+ }
+ if (
+ last
+ && last.kind === "trace"
+ && !last.isStreaming
+ && !!last.fileEdits?.length
+ && last.activitySegmentId === segmentId
+ ) {
+ const merged: UIMessage = {
+ ...last,
+ fileEdits: mergeFileEdits(last.fileEdits, edits),
+ activitySegmentId: last.activitySegmentId ?? segmentId,
+ };
+ return [...prev.slice(0, -1), merged];
+ }
+ return [
+ ...prev,
+ {
+ id: crypto.randomUUID(),
+ role: "tool",
+ kind: "trace",
+ content: "",
+ traces: [],
+ fileEdits: mergeFileEdits(undefined, edits),
+ activitySegmentId: segmentId,
+ createdAt: Date.now(),
+ },
+ ];
+ });
+ return;
+ }
// ``attached`` / ``error`` frames aren't actionable here; the client
// shell handles them separately.
};
@@ -594,6 +755,8 @@ export function useNanobotStream(
unsub();
buffer.current = null;
activeAssistantRef.current = null;
+ closedAssistantStreamIdsRef.current.clear();
+ clearActivitySegment();
clearPendingStreamWork();
if (streamEndTimerRef.current !== null) {
clearTimeout(streamEndTimerRef.current);
@@ -603,7 +766,10 @@ export function useNanobotStream(
}, [
chatId,
client,
+ clearActivitySegment,
clearPendingStreamWork,
+ detachedActivitySegmentId,
+ ensureActivitySegmentId,
flushPendingStreamEvents,
onTurnEnd,
schedulePendingStreamFlush,
@@ -622,6 +788,8 @@ export function useNanobotStream(
setMessages((prev) => {
buffer.current = null;
activeAssistantRef.current = null;
+ closedAssistantStreamIdsRef.current.clear();
+ clearActivitySegment();
return [
...pruneReasoningOnlyPlaceholders(prev),
{
@@ -643,7 +811,7 @@ export function useNanobotStream(
client.sendMessage(chatId, content, wireMedia);
}
},
- [chatId, client, flushPendingStreamEvents],
+ [chatId, clearActivitySegment, client, flushPendingStreamEvents],
);
const stop = useCallback(() => {
@@ -653,11 +821,13 @@ export function useNanobotStream(
setMessages((prev) => {
buffer.current = null;
activeAssistantRef.current = null;
+ closedAssistantStreamIdsRef.current.clear();
+ clearActivitySegment();
return prev.map((m) => (m.isStreaming ? { ...m, isStreaming: false } : m));
});
suppressStreamUntilTurnEndRef.current = false;
client.sendMessage(chatId, "/stop");
- }, [chatId, client, flushPendingStreamEvents]);
+ }, [chatId, clearActivitySegment, client, flushPendingStreamEvents]);
return {
messages,
diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts
index 59ad8566..8ffb4a70 100644
--- a/webui/src/lib/types.ts
+++ b/webui/src/lib/types.ts
@@ -40,6 +40,10 @@ export interface UIMessage {
/** For trace rows: each individual hint line, so consecutive hints can
* render as a single collapsible group. */
traces?: string[];
+ /** Activity rows: explicit file edits emitted by edit tools. */
+ fileEdits?: UIFileEdit[];
+ /** Activity rows created during the same agent phase share one collapsible block. */
+ activitySegmentId?: string;
/** User turn: optimistic blob URLs for preview. Replay: placeholder chips. */
images?: UIImage[];
/** Signed or local UI-renderable media attachments. */
@@ -80,6 +84,20 @@ export interface ToolProgressEvent {
embeds?: unknown[];
}
+export interface UIFileEdit {
+ version?: number;
+ call_id: string;
+ tool: string;
+ path: string;
+ phase?: "start" | "end" | "error" | string;
+ added: number;
+ deleted: number;
+ approximate?: boolean;
+ status: "editing" | "done" | "error";
+ binary?: boolean;
+ error?: string;
+}
+
export interface ChatSummary {
/** Server-side session key, e.g. ``websocket:abcd-...``. */
key: string;
@@ -183,6 +201,11 @@ export type InboundEvent =
/** Optional structured payload on progress frames (channel-specific). */
agent_ui?: AgentUIBlob;
}
+ | {
+ event: "file_edit";
+ chat_id: string;
+ edits: UIFileEdit[];
+ }
| {
event: "delta";
chat_id: string;
@@ -230,7 +253,7 @@ export type InboundEvent =
chat_id: string;
goal_state: GoalStateWsPayload;
}
- | { event: "session_updated"; chat_id: string }
+ | { event: "session_updated"; chat_id: string; scope?: "metadata" | "thread" | string }
| { event: "error"; chat_id?: string; detail?: string };
/** Base64-encoded image attached to an outbound ``message`` envelope.
diff --git a/webui/src/tests/agent-activity-cluster.test.tsx b/webui/src/tests/agent-activity-cluster.test.tsx
index e6bffd38..12026850 100644
--- a/webui/src/tests/agent-activity-cluster.test.tsx
+++ b/webui/src/tests/agent-activity-cluster.test.tsx
@@ -1,4 +1,4 @@
-import { act, fireEvent, render, screen } from "@testing-library/react";
+import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { AgentActivityCluster } from "@/components/thread/AgentActivityCluster";
@@ -72,6 +72,25 @@ function setScrollGeometry(
});
}
+function installReducedMotion() {
+ const original = window.matchMedia;
+ Object.defineProperty(window, "matchMedia", {
+ configurable: true,
+ value: () => ({
+ matches: true,
+ media: "(prefers-reduced-motion: reduce)",
+ addEventListener: () => {},
+ removeEventListener: () => {},
+ }),
+ });
+ return () => {
+ Object.defineProperty(window, "matchMedia", {
+ configurable: true,
+ value: original,
+ });
+ };
+}
+
describe("AgentActivityCluster", () => {
it("jumps to the latest activity when opened", () => {
const raf = installAnimationFrameQueue();
@@ -201,4 +220,117 @@ describe("AgentActivityCluster", () => {
raf.restore();
}
});
+
+ it("renders file edit totals and a compact expanded file list", async () => {
+ const restoreMotion = installReducedMotion();
+ try {
+ render(
+ ,
+ );
+
+ expect(screen.getByRole("button", { name: /edited app\.tsx/i })).toBeInTheDocument();
+ fireEvent.click(screen.getByRole("button", { name: /edited app\.tsx/i }));
+
+ expect(screen.queryByText("Edited files")).not.toBeInTheDocument();
+ expect(screen.queryByText("Edited")).not.toBeInTheDocument();
+ const fileRef = screen.getByTestId("activity-file-reference");
+ expect(fileRef).toHaveTextContent("src/app.tsx");
+ expect(fileRef).toHaveAttribute("aria-label", "src/app.tsx");
+ await waitFor(() => {
+ expect(screen.getAllByText("+12").length).toBeGreaterThan(0);
+ expect(screen.getAllByText("-3").length).toBeGreaterThan(0);
+ });
+ } finally {
+ restoreMotion();
+ }
+ });
+
+ it("merges repeated edits for the same path and lets successful edits win over failures", async () => {
+ const restoreMotion = installReducedMotion();
+ try {
+ render(
+ ,
+ );
+
+ expect(screen.getByRole("button", { name: /edited index\.html/i })).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: /failed index\.html/i })).not.toBeInTheDocument();
+ fireEvent.click(screen.getByRole("button", { name: /edited index\.html/i }));
+
+ const fileRefs = screen.getAllByTestId("activity-file-reference");
+ expect(fileRefs).toHaveLength(1);
+ expect(fileRefs[0]).toHaveTextContent("minecraft-fps/index.html");
+ expect(screen.queryByText("Failed")).not.toBeInTheDocument();
+ await waitFor(() => {
+ expect(screen.getAllByText("+8").length).toBeGreaterThan(0);
+ expect(screen.getAllByText("-7").length).toBeGreaterThan(0);
+ });
+ } finally {
+ restoreMotion();
+ }
+ });
});
diff --git a/webui/src/tests/thread-messages.test.tsx b/webui/src/tests/thread-messages.test.tsx
index 4e7711fa..7b3f2150 100644
--- a/webui/src/tests/thread-messages.test.tsx
+++ b/webui/src/tests/thread-messages.test.tsx
@@ -55,6 +55,153 @@ describe("ThreadMessages", () => {
expect(rows[1]).toHaveClass("mt-4");
});
+ it("starts a new activity cluster when the activity segment changes", () => {
+ const messages: UIMessage[] = [
+ {
+ id: "r1",
+ role: "assistant",
+ content: "",
+ reasoning: "first pass",
+ activitySegmentId: "seg-1",
+ createdAt: 1,
+ },
+ {
+ id: "t1",
+ role: "tool",
+ kind: "trace",
+ content: "edit_file()",
+ traces: ["edit_file()"],
+ fileEdits: [{
+ call_id: "call-edit",
+ tool: "edit_file",
+ path: "foo.txt",
+ phase: "end",
+ added: 2,
+ deleted: 1,
+ status: "done",
+ }],
+ activitySegmentId: "seg-1",
+ createdAt: 2,
+ },
+ {
+ id: "r2",
+ role: "assistant",
+ content: "",
+ reasoning: "second pass",
+ activitySegmentId: "seg-2",
+ createdAt: 3,
+ },
+ ];
+
+ const units = buildDisplayUnits(messages);
+
+ expect(units).toHaveLength(2);
+ expect(units[0].type === "cluster" ? units[0].messages.map((m) => m.id) : []).toEqual([
+ "r1",
+ "t1",
+ ]);
+ expect(units[1].type === "cluster" ? units[1].messages.map((m) => m.id) : []).toEqual([
+ "r2",
+ ]);
+ });
+
+ it("does not split ordinary tool activity just because segment ids changed", () => {
+ const messages: UIMessage[] = [
+ {
+ id: "r1",
+ role: "assistant",
+ content: "",
+ reasoning: "first pass",
+ activitySegmentId: "seg-1",
+ createdAt: 1,
+ },
+ {
+ id: "t1",
+ role: "tool",
+ kind: "trace",
+ content: "read_file()",
+ traces: ["read_file()"],
+ activitySegmentId: "seg-1",
+ createdAt: 2,
+ },
+ {
+ id: "r2",
+ role: "assistant",
+ content: "",
+ reasoning: "second pass",
+ activitySegmentId: "seg-2",
+ createdAt: 3,
+ },
+ {
+ id: "t2",
+ role: "tool",
+ kind: "trace",
+ content: "grep()",
+ traces: ["grep()"],
+ activitySegmentId: "seg-2",
+ createdAt: 4,
+ },
+ ];
+
+ const units = buildDisplayUnits(messages);
+
+ expect(units).toHaveLength(1);
+ expect(units[0].type === "cluster" ? units[0].messages.map((m) => m.id) : []).toEqual([
+ "r1",
+ "t1",
+ "r2",
+ "t2",
+ ]);
+ });
+
+ it("only marks the current activity cluster as live while streaming", () => {
+ const messages: UIMessage[] = [
+ {
+ id: "r1",
+ role: "assistant",
+ content: "",
+ reasoning: "first pass",
+ reasoningStreaming: true,
+ activitySegmentId: "seg-1",
+ createdAt: 1,
+ },
+ {
+ id: "t1",
+ role: "tool",
+ kind: "trace",
+ content: "edit_file()",
+ traces: ["edit_file()"],
+ fileEdits: [{
+ call_id: "call-edit",
+ tool: "edit_file",
+ path: "foo.txt",
+ phase: "start",
+ added: 4,
+ deleted: 1,
+ approximate: true,
+ status: "editing",
+ }],
+ activitySegmentId: "seg-1",
+ createdAt: 2,
+ },
+ {
+ id: "r2",
+ role: "assistant",
+ content: "",
+ reasoning: "second pass",
+ reasoningStreaming: true,
+ activitySegmentId: "seg-2",
+ createdAt: 3,
+ },
+ ];
+
+ render();
+
+ expect(screen.getByRole("button", { name: /edited foo\.txt/i })).toBeInTheDocument();
+ expect(screen.queryByRole("button", { name: /editing foo\.txt/i })).not.toBeInTheDocument();
+ expect(screen.getByRole("button", { name: /working/i })).toBeInTheDocument();
+ });
+
it("folds final answer reasoning into the preceding activity cluster", () => {
const messages: UIMessage[] = [
{
diff --git a/webui/src/tests/useNanobotStream.test.tsx b/webui/src/tests/useNanobotStream.test.tsx
index 0f736a01..925102da 100644
--- a/webui/src/tests/useNanobotStream.test.tsx
+++ b/webui/src/tests/useNanobotStream.test.tsx
@@ -308,6 +308,173 @@ describe("useNanobotStream", () => {
);
});
+ it("renders live file_edit events as their own activity trace", () => {
+ const fake = fakeClient();
+ const { result } = renderHook(() => useNanobotStream("chat-file-edit", EMPTY_MESSAGES), {
+ wrapper: wrap(fake.client),
+ });
+
+ act(() => {
+ fake.emit("chat-file-edit", {
+ event: "message",
+ chat_id: "chat-file-edit",
+ text: 'write_file({"path":"foo.txt"})',
+ kind: "tool_hint",
+ });
+ fake.emit("chat-file-edit", {
+ event: "file_edit",
+ chat_id: "chat-file-edit",
+ edits: [{
+ call_id: "call-write",
+ tool: "write_file",
+ path: "foo.txt",
+ phase: "start",
+ added: 1,
+ deleted: 0,
+ approximate: true,
+ status: "editing",
+ }],
+ });
+ fake.emit("chat-file-edit", {
+ event: "file_edit",
+ chat_id: "chat-file-edit",
+ edits: [{
+ call_id: "call-write",
+ tool: "write_file",
+ path: "foo.txt",
+ phase: "end",
+ added: 3,
+ deleted: 1,
+ approximate: false,
+ status: "done",
+ }],
+ });
+ });
+
+ expect(result.current.messages).toHaveLength(2);
+ expect(result.current.messages[0]).toMatchObject({
+ role: "tool",
+ kind: "trace",
+ traces: ['write_file({"path":"foo.txt"})'],
+ });
+ expect(result.current.messages[1]).toMatchObject({
+ role: "tool",
+ kind: "trace",
+ fileEdits: [{
+ call_id: "call-write",
+ status: "done",
+ added: 3,
+ deleted: 1,
+ approximate: false,
+ }],
+ });
+ expect(result.current.messages[1].activitySegmentId).toBeTruthy();
+ expect(result.current.messages[1].activitySegmentId).not.toBe(
+ result.current.messages[0].activitySegmentId,
+ );
+ });
+
+ it("starts a new assistant bubble for deltas after stream_end and activity", async () => {
+ const fake = fakeClient();
+ const { result } = renderHook(() => useNanobotStream("chat-stream-segments", EMPTY_MESSAGES), {
+ wrapper: wrap(fake.client),
+ });
+
+ act(() => {
+ fake.emit("chat-stream-segments", {
+ event: "delta",
+ chat_id: "chat-stream-segments",
+ text: "I created the files.",
+ });
+ fake.emit("chat-stream-segments", {
+ event: "stream_end",
+ chat_id: "chat-stream-segments",
+ });
+ fake.emit("chat-stream-segments", {
+ event: "message",
+ chat_id: "chat-stream-segments",
+ text: 'write_file({"path":"minecraft-fps/options.txt"})',
+ kind: "tool_hint",
+ });
+ fake.emit("chat-stream-segments", {
+ event: "delta",
+ chat_id: "chat-stream-segments",
+ text: "Now I will summarize the edits.",
+ });
+ });
+
+ await flushStreamFrame();
+
+ expect(result.current.messages).toHaveLength(3);
+ expect(result.current.messages[0]).toMatchObject({
+ role: "assistant",
+ content: "I created the files.",
+ });
+ expect(result.current.messages[1]).toMatchObject({
+ role: "tool",
+ kind: "trace",
+ traces: ['write_file({"path":"minecraft-fps/options.txt"})'],
+ });
+ expect(result.current.messages[2]).toMatchObject({
+ role: "assistant",
+ content: "Now I will summarize the edits.",
+ });
+ });
+
+ it("opens a new activity segment for reasoning after file edit activity", async () => {
+ const fake = fakeClient();
+ const { result } = renderHook(() => useNanobotStream("chat-file-segments", EMPTY_MESSAGES), {
+ wrapper: wrap(fake.client),
+ });
+
+ act(() => {
+ fake.emit("chat-file-segments", {
+ event: "reasoning_delta",
+ chat_id: "chat-file-segments",
+ text: "Plan.",
+ });
+ fake.emit("chat-file-segments", {
+ event: "reasoning_end",
+ chat_id: "chat-file-segments",
+ });
+ fake.emit("chat-file-segments", {
+ event: "message",
+ chat_id: "chat-file-segments",
+ text: 'edit_file({"path":"foo.txt"})',
+ kind: "tool_hint",
+ });
+ fake.emit("chat-file-segments", {
+ event: "file_edit",
+ chat_id: "chat-file-segments",
+ edits: [{
+ call_id: "call-edit",
+ tool: "edit_file",
+ path: "foo.txt",
+ phase: "start",
+ added: 1,
+ deleted: 1,
+ approximate: true,
+ status: "editing",
+ }],
+ });
+ fake.emit("chat-file-segments", {
+ event: "reasoning_delta",
+ chat_id: "chat-file-segments",
+ text: "Review result.",
+ });
+ });
+
+ await flushStreamFrame();
+
+ expect(result.current.messages).toHaveLength(4);
+ const firstSegment = result.current.messages[0].activitySegmentId;
+ expect(firstSegment).toBeTruthy();
+ expect(result.current.messages[1].activitySegmentId).toBe(firstSegment);
+ expect(result.current.messages[2].activitySegmentId).toBeTruthy();
+ expect(result.current.messages[2].activitySegmentId).not.toBe(firstSegment);
+ expect(result.current.messages[3].activitySegmentId).toBe(firstSegment);
+ });
+
it("accumulates reasoning_delta chunks on a placeholder until reasoning_end", async () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-r", EMPTY_MESSAGES), {
From 361f31c0e4ecc16430a0e5513cfe90d4b3822a8d Mon Sep 17 00:00:00 2001
From: Xubin Ren <52506698+Re-bin@users.noreply.github.com>
Date: Sun, 17 May 2026 23:52:29 +0800
Subject: [PATCH 06/12] fix(webui): use portal file reference tooltips
---
webui/src/components/MarkdownTextRenderer.tsx | 4 ++
webui/src/components/ui/tooltip.tsx | 20 +++++----
webui/src/tests/message-bubble.test.tsx | 43 ++++++++++++++++++-
3 files changed, 57 insertions(+), 10 deletions(-)
diff --git a/webui/src/components/MarkdownTextRenderer.tsx b/webui/src/components/MarkdownTextRenderer.tsx
index ff75004a..aa757ff0 100644
--- a/webui/src/components/MarkdownTextRenderer.tsx
+++ b/webui/src/components/MarkdownTextRenderer.tsx
@@ -6,6 +6,7 @@ import remarkGfm from "remark-gfm";
import remarkMath from "remark-math";
import { CodeBlock } from "@/components/CodeBlock";
+import { FileReferenceChip, isLikelyFilePath } from "@/components/FileReferenceChip";
import { cn } from "@/lib/utils";
import "katex/dist/katex.min.css";
@@ -44,6 +45,9 @@ export default function MarkdownTextRenderer({
);
}
const raw = String(kids).replace(/\n$/, "");
+ if (isLikelyFilePath(raw)) {
+ return ;
+ }
/** Plain fenced ``` blocks (no language) & wide one-liners: block monospace, not inline pill. */
const widePlainBlock = raw.includes("\n") || raw.length > 120;
if (widePlainBlock) {
diff --git a/webui/src/components/ui/tooltip.tsx b/webui/src/components/ui/tooltip.tsx
index 95f7960c..d69f1227 100644
--- a/webui/src/components/ui/tooltip.tsx
+++ b/webui/src/components/ui/tooltip.tsx
@@ -11,15 +11,17 @@ const TooltipContent = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef
>(({ className, sideOffset = 4, ...props }, ref) => (
-
+
+
+
));
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
diff --git a/webui/src/tests/message-bubble.test.tsx b/webui/src/tests/message-bubble.test.tsx
index 410fbaba..baae344d 100644
--- a/webui/src/tests/message-bubble.test.tsx
+++ b/webui/src/tests/message-bubble.test.tsx
@@ -1,4 +1,4 @@
-import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { MessageBubble } from "@/components/MessageBubble";
@@ -179,6 +179,47 @@ describe("MessageBubble", () => {
expect(screen.getByText("Body line.")).toBeInTheDocument();
});
+ it("renders inline file paths as compact file references", async () => {
+ await import("@/components/MarkdownTextRenderer");
+ const message: UIMessage = {
+ id: "a-file-path",
+ role: "assistant",
+ content:
+ "改动在 `webui/src/components/MarkdownTextRenderer.tsx` 和 `/Users/renxubin/.nanobot/workspace/minecraft-fps/index.html`。",
+ createdAt: Date.now(),
+ };
+
+ try {
+ render();
+
+ const references = await screen.findAllByTestId("inline-file-path");
+ expect(references).toHaveLength(2);
+ expect(references[0].parentElement).not.toHaveClass("translate-y-[0.08em]");
+ expect(references[0].parentElement).toHaveClass("align-[0.14em]");
+ expect(references[0]).toHaveTextContent("MarkdownTextRenderer.tsx");
+ expect(references[0]).not.toHaveTextContent("webui/src/components");
+ expect(screen.getByText("index.html")).toBeInTheDocument();
+ expect(references[1]).not.toHaveTextContent("/Users/renxubin");
+ expect(references[1]).not.toHaveAttribute("title");
+ expect(references[1]).toHaveAttribute(
+ "aria-label",
+ "/Users/renxubin/.nanobot/workspace/minecraft-fps/index.html",
+ );
+
+ vi.useFakeTimers();
+ fireEvent.pointerMove(references[1].parentElement!);
+ await act(async () => {
+ vi.advanceTimersByTime(500);
+ });
+ const tooltip = screen.getByRole("tooltip");
+ expect(tooltip).toHaveTextContent(
+ "/Users/renxubin/.nanobot/workspace/minecraft-fps/index.html",
+ );
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
it("renders assistant image media as a larger generated result", () => {
const message: UIMessage = {
id: "a-image",
From 2f323e24c14ec4f35e135c28acc52ad6698f64a6 Mon Sep 17 00:00:00 2001
From: Xubin Ren <52506698+Re-bin@users.noreply.github.com>
Date: Sun, 17 May 2026 23:52:50 +0800
Subject: [PATCH 07/12] fix(webui): polish session titles and status
---
webui/src/App.tsx | 4 +-
webui/src/components/ChatList.tsx | 10 ++--
webui/src/components/ConnectionBadge.tsx | 12 +++--
webui/src/components/Sidebar.tsx | 4 +-
webui/src/components/thread/ThreadShell.tsx | 3 +-
webui/src/lib/format.ts | 25 ++++++++++
webui/src/lib/nanobot-client.ts | 9 ++--
webui/src/tests/nanobot-client.test.ts | 8 ++-
webui/src/tests/thread-shell.test.tsx | 54 +++++++++++++++++++--
webui/src/tests/useSessions.test.tsx | 32 ++++++++++--
10 files changed, 134 insertions(+), 27 deletions(-)
diff --git a/webui/src/App.tsx b/webui/src/App.tsx
index fabcff18..591cf4a9 100644
--- a/webui/src/App.tsx
+++ b/webui/src/App.tsx
@@ -17,6 +17,7 @@ import {
loadSavedSecret,
saveSecret,
} from "@/lib/bootstrap";
+import { deriveTitle } from "@/lib/format";
import { NanobotClient } from "@/lib/nanobot-client";
import { ClientProvider, useClient } from "@/providers/ClientProvider";
import type { ChatSummary } from "@/lib/types";
@@ -391,8 +392,7 @@ function Shell({
const headerTitle = activeSession
? activeSession.title ||
- activeSession.preview ||
- t("chat.fallbackTitle", { id: activeSession.chatId.slice(0, 6) })
+ deriveTitle(activeSession.preview, t("chat.newChat"))
: t("app.brand");
useEffect(() => {
diff --git a/webui/src/components/ChatList.tsx b/webui/src/components/ChatList.tsx
index fc667883..a5107651 100644
--- a/webui/src/components/ChatList.tsx
+++ b/webui/src/components/ChatList.tsx
@@ -7,6 +7,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
+import { deriveTitle } from "@/lib/format";
import { cn } from "@/lib/utils";
import type { ChatSummary } from "@/lib/types";
@@ -64,8 +65,11 @@ export function ChatList({
const fallbackTitle = t("chat.fallbackTitle", {
id: s.chatId.slice(0, 6),
});
- const rawLabel = (s.title || s.preview)?.trim();
- const title = rawLabel || fallbackTitle;
+ const generatedTitle = s.title?.trim() || "";
+ const title =
+ generatedTitle || deriveTitle(s.preview, t("chat.newChat"));
+ const tooltipTitle =
+ generatedTitle || deriveTitle(s.preview, fallbackTitle);
return (
onSelect(s.key)}
- title={rawLabel || fallbackTitle}
+ title={tooltipTitle}
className="min-w-0 flex-1 overflow-hidden py-1.5 text-left"
>
{title}
diff --git a/webui/src/components/ConnectionBadge.tsx b/webui/src/components/ConnectionBadge.tsx
index 7616ddbe..a09aadd2 100644
--- a/webui/src/components/ConnectionBadge.tsx
+++ b/webui/src/components/ConnectionBadge.tsx
@@ -36,21 +36,25 @@ export function ConnectionBadge() {
status === "connecting" ||
status === "reconnecting" ||
status === "error";
+ const label = t(`connection.${status}`);
return (
-
+
{pulsing && (
)}
-
+
- {t(`connection.${status}`)}
+ {label}
);
}
diff --git a/webui/src/components/Sidebar.tsx b/webui/src/components/Sidebar.tsx
index cf21c886..cd55475f 100644
--- a/webui/src/components/Sidebar.tsx
+++ b/webui/src/components/Sidebar.tsx
@@ -117,12 +117,12 @@ export function Sidebar(props: SidebarProps) {
/>
-
+