feat: add file edit diff progress view

Capture file edit snapshots through runner tool lifecycle hooks and render unified diffs in the WebUI with folding and truncation controls.
This commit is contained in:
chengyongru
2026-07-09 10:42:43 +08:00
committed by Xubin Ren
parent 207813d3b5
commit 7768672c5b
40 changed files with 2224 additions and 1753 deletions
+72
View File
@@ -93,6 +93,35 @@ class AgentHook:
async def before_execute_tools(self, context: AgentHookContext) -> None: async def before_execute_tools(self, context: AgentHookContext) -> None:
pass pass
async def before_execute_tool(
self,
context: AgentHookContext,
tool_call: ToolCallRequest,
tool: Any,
params: Any,
) -> None:
pass
async def after_execute_tool(
self,
context: AgentHookContext,
tool_call: ToolCallRequest,
tool: Any,
params: Any,
result: Any,
) -> None:
pass
async def on_execute_tool_error(
self,
context: AgentHookContext,
tool_call: ToolCallRequest,
tool: Any,
params: Any,
error: Any,
) -> None:
pass
async def emit_reasoning(self, reasoning_content: str | None) -> None: async def emit_reasoning(self, reasoning_content: str | None) -> None:
pass pass
@@ -166,6 +195,49 @@ class CompositeHook(AgentHook):
async def before_execute_tools(self, context: AgentHookContext) -> None: async def before_execute_tools(self, context: AgentHookContext) -> None:
await self._for_each_hook_safe("before_execute_tools", context) await self._for_each_hook_safe("before_execute_tools", context)
async def before_execute_tool(
self,
context: AgentHookContext,
tool_call: ToolCallRequest,
tool: Any,
params: Any,
) -> None:
await self._for_each_hook_safe("before_execute_tool", context, tool_call, tool, params)
async def after_execute_tool(
self,
context: AgentHookContext,
tool_call: ToolCallRequest,
tool: Any,
params: Any,
result: Any,
) -> None:
await self._for_each_hook_safe(
"after_execute_tool",
context,
tool_call,
tool,
params,
result,
)
async def on_execute_tool_error(
self,
context: AgentHookContext,
tool_call: ToolCallRequest,
tool: Any,
params: Any,
error: Any,
) -> None:
await self._for_each_hook_safe(
"on_execute_tool_error",
context,
tool_call,
tool,
params,
error,
)
async def emit_reasoning(self, reasoning_content: str | None) -> None: async def emit_reasoning(self, reasoning_content: str | None) -> None:
await self._for_each_hook_safe("emit_reasoning", reasoning_content) await self._for_each_hook_safe("emit_reasoning", reasoning_content)
+11
View File
@@ -0,0 +1,11 @@
"""Concrete agent hook implementations."""
from nanobot.agent.hooks.file_edit_activity import (
FileEditActivityHook,
create_file_edit_activity_hook,
)
__all__ = [
"FileEditActivityHook",
"create_file_edit_activity_hook",
]
+109
View File
@@ -0,0 +1,109 @@
"""Agent hook that observes file-editing tools and emits file-edit activity."""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from pathlib import Path
from typing import Any
from nanobot.agent.hook import AgentHook, AgentHookContext, AgentTurnHookContext
from nanobot.providers.base import ToolCallRequest
from nanobot.utils.file_edit_events import (
FileEditTracker,
build_file_edit_end_event,
build_file_edit_error_event,
build_file_edit_start_event,
prepare_file_edit_trackers,
)
from nanobot.utils.progress_events import (
invoke_file_edit_progress,
on_progress_accepts_file_edit_events,
)
class FileEditActivityHook(AgentHook):
"""Translate file-editing tool lifecycle events into WebUI progress events."""
def __init__(
self,
*,
on_progress: Callable[..., Awaitable[None]] | None,
workspace: Path | None,
) -> None:
super().__init__()
self._on_progress = (
on_progress
if on_progress is not None and on_progress_accepts_file_edit_events(on_progress)
else None
)
self._workspace = workspace
self._trackers_by_call: dict[str, list[FileEditTracker]] = {}
async def before_iteration(self, context: AgentHookContext) -> None:
self._trackers_by_call.clear()
async def before_execute_tool(
self,
context: AgentHookContext,
tool_call: ToolCallRequest,
tool: Any,
params: Any,
) -> None:
if self._on_progress is None or not isinstance(params, dict):
return
trackers = prepare_file_edit_trackers(
call_id=tool_call.id,
tool_name=tool_call.name,
tool=tool,
workspace=self._workspace,
params=params,
)
if not trackers:
return
self._trackers_by_call[self._tool_call_key(tool_call)] = trackers
await self._emit([build_file_edit_start_event(tracker, params) for tracker in trackers])
async def after_execute_tool(
self,
context: AgentHookContext,
tool_call: ToolCallRequest,
tool: Any,
params: Any,
result: Any,
) -> None:
trackers = self._trackers_by_call.pop(self._tool_call_key(tool_call), [])
if trackers:
await self._emit([build_file_edit_end_event(tracker) for tracker in trackers])
async def on_execute_tool_error(
self,
context: AgentHookContext,
tool_call: ToolCallRequest,
tool: Any,
params: Any,
error: Any,
) -> None:
trackers = self._trackers_by_call.pop(self._tool_call_key(tool_call), [])
if trackers:
await self._emit([
build_file_edit_error_event(tracker, str(error)) for tracker in trackers
])
async def _emit(self, events: list[dict[str, Any]]) -> None:
if self._on_progress is not None:
await invoke_file_edit_progress(self._on_progress, events)
@staticmethod
def _tool_call_key(tool_call: ToolCallRequest) -> str:
call_id = getattr(tool_call, "id", "") or ""
return f"{call_id}|{tool_call.name}" if call_id else f"{id(tool_call)}|{tool_call.name}"
def create_file_edit_activity_hook(context: AgentTurnHookContext) -> AgentHook | None:
"""Create the default file-edit observer for one agent turn."""
if context.on_progress is None:
return None
return FileEditActivityHook(
on_progress=context.on_progress,
workspace=context.workspace,
)
+26 -96
View File
@@ -21,16 +21,6 @@ from nanobot.agent.hook import AgentHook, AgentHookContext, AgentRunHookContext
from nanobot.agent.tools.registry import ToolRegistry, is_tool_error_result from nanobot.agent.tools.registry import ToolRegistry, is_tool_error_result
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
from nanobot.session.history_visibility import is_hidden_history_message from nanobot.session.history_visibility import is_hidden_history_message
from nanobot.utils.file_edit_events import (
StreamingFileEditTracker,
build_file_edit_end_event,
build_file_edit_error_event,
build_file_edit_start_event,
prepare_file_edit_trackers,
)
from nanobot.utils.file_edit_events import (
prepare_file_edit_tracker as _prepare_file_edit_tracker,
)
from nanobot.utils.helpers import ( from nanobot.utils.helpers import (
IncrementalThinkExtractor, IncrementalThinkExtractor,
build_assistant_message, build_assistant_message,
@@ -40,10 +30,6 @@ from nanobot.utils.helpers import (
strip_reasoning_tags, strip_reasoning_tags,
strip_think, strip_think,
) )
from nanobot.utils.progress_events import (
invoke_file_edit_progress,
on_progress_accepts_file_edit_events,
)
from nanobot.utils.prompt_templates import render_template from nanobot.utils.prompt_templates import render_template
from nanobot.utils.runtime import ( from nanobot.utils.runtime import (
EMPTY_FINAL_RESPONSE_MESSAGE, EMPTY_FINAL_RESPONSE_MESSAGE,
@@ -68,10 +54,6 @@ _MAX_EMPTY_RETRIES = 2
_MAX_LENGTH_RECOVERIES = 3 _MAX_LENGTH_RECOVERIES = 3
_MAX_INJECTIONS_PER_TURN = 3 _MAX_INJECTIONS_PER_TURN = 3
_MAX_INJECTION_CYCLES = 5 _MAX_INJECTION_CYCLES = 5
# Backward-compatible module attribute for tests/extensions that monkeypatch
# the former single-file tracker hook. Runtime uses prepare_file_edit_trackers.
prepare_file_edit_tracker = _prepare_file_edit_tracker
@dataclass(slots=True) @dataclass(slots=True)
class AgentRunSpec: class AgentRunSpec:
@@ -461,6 +443,8 @@ class AgentRunner:
response.tool_calls, response.tool_calls,
external_lookup_counts, external_lookup_counts,
workspace_violation_counts, workspace_violation_counts,
hook,
context,
) )
tool_events.extend(new_events) tool_events.extend(new_events)
tools_used.extend( tools_used.extend(
@@ -766,24 +750,6 @@ class AgentRunner:
) )
progress_state: dict[str, bool] | None = None progress_state: dict[str, bool] | None = None
live_file_edits: StreamingFileEditTracker | None = None
if (
spec.progress_callback is not None
and on_progress_accepts_file_edit_events(spec.progress_callback)
):
async def _emit_live_file_edits(events: list[dict[str, Any]]) -> None:
await invoke_file_edit_progress(spec.progress_callback, events)
live_file_edits = StreamingFileEditTracker(
workspace=spec.workspace,
tools=spec.tools,
emit=_emit_live_file_edits,
)
async def _tool_call_delta(delta: dict[str, Any]) -> None:
if live_file_edits is not None:
await live_file_edits.update(delta)
if wants_streaming: if wants_streaming:
thinking_buf = "" thinking_buf = ""
@@ -812,7 +778,6 @@ class AgentRunner:
**kwargs, **kwargs,
on_content_delta=_stream, on_content_delta=_stream,
on_thinking_delta=_thinking, on_thinking_delta=_thinking,
on_tool_call_delta=_tool_call_delta if live_file_edits is not None else None,
on_stream_recover=_stream_recover, on_stream_recover=_stream_recover,
) )
elif wants_progress_streaming: elif wants_progress_streaming:
@@ -843,7 +808,6 @@ class AgentRunner:
coro = self.provider.chat_stream_with_retry( coro = self.provider.chat_stream_with_retry(
**kwargs, **kwargs,
on_content_delta=_stream_progress, on_content_delta=_stream_progress,
on_tool_call_delta=_tool_call_delta if live_file_edits is not None else None,
) )
else: else:
coro = self.provider.chat_with_retry(**kwargs) coro = self.provider.chat_with_retry(**kwargs)
@@ -858,14 +822,6 @@ class AgentRunner:
await coro if outer_timeout_s is None await coro if outer_timeout_s is None
else await asyncio.wait_for(coro, timeout=outer_timeout_s) else await asyncio.wait_for(coro, timeout=outer_timeout_s)
) )
if live_file_edits is not None:
await live_file_edits.flush()
if response.should_execute_tools:
live_file_edits.apply_final_call_ids(response.tool_calls)
await live_file_edits.error_unmatched(
response.tool_calls if response.should_execute_tools else [],
"Tool call did not complete.",
)
except asyncio.TimeoutError: except asyncio.TimeoutError:
if outer_timeout_s is None: if outer_timeout_s is None:
return LLMResponse( return LLMResponse(
@@ -1131,14 +1087,23 @@ class AgentRunner:
tool_calls: list[ToolCallRequest], tool_calls: list[ToolCallRequest],
external_lookup_counts: dict[str, int], external_lookup_counts: dict[str, int],
workspace_violation_counts: dict[str, int], workspace_violation_counts: dict[str, int],
hook: AgentHook | None = None,
context: AgentHookContext | None = None,
) -> tuple[list[Any], list[dict[str, str]], BaseException | None]: ) -> tuple[list[Any], list[dict[str, str]], BaseException | None]:
hook = hook or AgentHook()
context = context or AgentHookContext(iteration=0, messages=[])
batches = self._partition_tool_batches(spec, tool_calls) batches = self._partition_tool_batches(spec, tool_calls)
tool_results: list[tuple[Any, dict[str, str], BaseException | None]] = [] tool_results: list[tuple[Any, dict[str, str], BaseException | None]] = []
for batch in batches: for batch in batches:
if spec.concurrent_tools and len(batch) > 1: if spec.concurrent_tools and len(batch) > 1:
batch_results = await asyncio.gather(*( batch_results = await asyncio.gather(*(
self._run_tool( self._run_tool(
spec, tool_call, external_lookup_counts, workspace_violation_counts, spec,
tool_call,
external_lookup_counts,
workspace_violation_counts,
hook,
context,
) )
for tool_call in batch for tool_call in batch
)) ))
@@ -1147,7 +1112,12 @@ class AgentRunner:
batch_results = [] batch_results = []
for tool_call in batch: for tool_call in batch:
result = await self._run_tool( result = await self._run_tool(
spec, tool_call, external_lookup_counts, workspace_violation_counts, spec,
tool_call,
external_lookup_counts,
workspace_violation_counts,
hook,
context,
) )
tool_results.append(result) tool_results.append(result)
batch_results.append(result) batch_results.append(result)
@@ -1168,7 +1138,11 @@ class AgentRunner:
tool_call: ToolCallRequest, tool_call: ToolCallRequest,
external_lookup_counts: dict[str, int], external_lookup_counts: dict[str, int],
workspace_violation_counts: dict[str, int], workspace_violation_counts: dict[str, int],
hook: AgentHook | None = None,
context: AgentHookContext | None = None,
) -> tuple[Any, dict[str, str], BaseException | None]: ) -> tuple[Any, dict[str, str], BaseException | None]:
hook = hook or AgentHook()
context = context or AgentHookContext(iteration=0, messages=[])
hint = "\n\n[Analyze the error above and try a different approach.]" hint = "\n\n[Analyze the error above and try a different approach.]"
lookup_error = repeated_external_lookup_error( lookup_error = repeated_external_lookup_error(
tool_call.name, tool_call.name,
@@ -1209,30 +1183,7 @@ class AgentRunner:
return prep_error + hint, event, ( return prep_error + hint, event, (
RuntimeError(prep_error) if spec.fail_on_tool_error else None RuntimeError(prep_error) if spec.fail_on_tool_error else None
) )
emit_file_edit_events = ( await hook.before_execute_tool(context, tool_call, tool, params)
spec.progress_callback is not None
and on_progress_accepts_file_edit_events(spec.progress_callback)
)
progress_callback = spec.progress_callback if emit_file_edit_events else None
file_edit_trackers = (
prepare_file_edit_trackers(
call_id=tool_call.id,
tool_name=tool_call.name,
tool=tool,
workspace=spec.workspace,
params=params if isinstance(params, dict) else None,
)
if progress_callback is not None
else None
)
if file_edit_trackers and progress_callback is not None:
await invoke_file_edit_progress(
progress_callback,
[build_file_edit_start_event(
file_edit_tracker,
params if isinstance(params, dict) else None,
) for file_edit_tracker in file_edit_trackers],
)
try: try:
if tool is not None: if tool is not None:
result = await tool.execute(**params) result = await tool.execute(**params)
@@ -1241,14 +1192,7 @@ class AgentRunner:
except asyncio.CancelledError: except asyncio.CancelledError:
raise raise
except BaseException as exc: except BaseException as exc:
if file_edit_trackers and progress_callback is not None: await hook.on_execute_tool_error(context, tool_call, tool, params, exc)
await invoke_file_edit_progress(
progress_callback,
[
build_file_edit_error_event(file_edit_tracker, str(exc))
for file_edit_tracker in file_edit_trackers
],
)
event = { event = {
"name": tool_call.name, "name": tool_call.name,
"status": "error", "status": "error",
@@ -1270,14 +1214,7 @@ class AgentRunner:
return payload, event, None return payload, event, None
if is_tool_error_result(tool_call.name, result): if is_tool_error_result(tool_call.name, result):
if file_edit_trackers and progress_callback is not None: await hook.on_execute_tool_error(context, tool_call, tool, params, result)
await invoke_file_edit_progress(
progress_callback,
[
build_file_edit_error_event(file_edit_tracker, result)
for file_edit_tracker in file_edit_trackers
],
)
event = { event = {
"name": tool_call.name, "name": tool_call.name,
"status": "error", "status": "error",
@@ -1296,14 +1233,7 @@ class AgentRunner:
return result + hint, event, RuntimeError(result) return result + hint, event, RuntimeError(result)
return result + hint, event, None return result + hint, event, None
if file_edit_trackers and progress_callback is not None: await hook.after_execute_tool(context, tool_call, tool, params, result)
await invoke_file_edit_progress(
progress_callback,
[build_file_edit_end_event(
file_edit_tracker,
params if isinstance(params, dict) else None,
) for file_edit_tracker in file_edit_trackers],
)
detail = "" if result is None else str(result) detail = "" if result is None else str(result)
detail = detail.replace("\n", " ").strip() detail = detail.replace("\n", " ").strip()
+4
View File
@@ -61,6 +61,7 @@ from rich.text import Text # noqa: E402
from nanobot import __logo__, __version__ # noqa: E402 from nanobot import __logo__, __version__ # noqa: E402
from nanobot import optional_features as feature_support # noqa: E402 from nanobot import optional_features as feature_support # noqa: E402
from nanobot.agent.hooks import create_file_edit_activity_hook # noqa: E402
from nanobot.agent.loop import AgentLoop # noqa: E402 from nanobot.agent.loop import AgentLoop # noqa: E402
from nanobot.bus.outbound_events import ( # noqa: E402 from nanobot.bus.outbound_events import ( # noqa: E402
ProgressEvent, ProgressEvent,
@@ -1177,6 +1178,7 @@ def serve(
runtime_config, bus, runtime_config, bus,
session_manager=session_manager, session_manager=session_manager,
image_generation_provider_configs=image_gen_provider_configs(runtime_config), image_generation_provider_configs=image_gen_provider_configs(runtime_config),
hook_factories=[create_file_edit_activity_hook],
) )
except ValueError as exc: except ValueError as exc:
console.print(f"[red]Error: {exc}[/red]") console.print(f"[red]Error: {exc}[/red]")
@@ -1429,6 +1431,7 @@ def _run_gateway(
provider_signature=provider_snapshot.signature, provider_signature=provider_snapshot.signature,
hooks=[TokenUsageHook(timezone_name=config.agents.defaults.timezone)], hooks=[TokenUsageHook(timezone_name=config.agents.defaults.timezone)],
local_trigger_store=trigger_store, local_trigger_store=trigger_store,
hook_factories=[create_file_edit_activity_hook],
) )
WebuiTurnCoordinator( WebuiTurnCoordinator(
bus=bus, bus=bus,
@@ -1914,6 +1917,7 @@ def agent(
config, bus, config, bus,
cron_service=cron, cron_service=cron,
image_generation_provider_configs=image_gen_provider_configs(config), image_generation_provider_configs=image_gen_provider_configs(config),
hook_factories=[create_file_edit_activity_hook],
) )
except ValueError as exc: except ValueError as exc:
console.print(f"[red]Error: {exc}[/red]") console.print(f"[red]Error: {exc}[/red]")
+2
View File
@@ -8,6 +8,7 @@ from pathlib import Path
from typing import Any from typing import Any
from nanobot.agent.hook import AgentHook, SDKCaptureHook from nanobot.agent.hook import AgentHook, SDKCaptureHook
from nanobot.agent.hooks import create_file_edit_activity_hook
from nanobot.agent.loop import AgentLoop from nanobot.agent.loop import AgentLoop
from nanobot.config.schema import Config from nanobot.config.schema import Config
from nanobot.providers.image_generation import image_gen_provider_configs from nanobot.providers.image_generation import image_gen_provider_configs
@@ -120,6 +121,7 @@ class Nanobot:
loop = AgentLoop.from_config( loop = AgentLoop.from_config(
config, config,
image_generation_provider_configs=image_gen_provider_configs(config), image_generation_provider_configs=image_gen_provider_configs(config),
hook_factories=[create_file_edit_activity_hook],
) )
return cls(loop, config=config) return cls(loop, config=config)
File diff suppressed because it is too large Load Diff
+43 -9
View File
@@ -1525,16 +1525,50 @@ def replay_transcript_to_ui_messages(
) )
): ):
return i return i
existing_tool_events = candidate.get("toolEvents")
if isinstance(existing_tool_events, list):
for event in existing_tool_events:
if not isinstance(event, dict):
continue
key = _tool_event_file_edit_key(event)
if key and key in incoming_tool_event_keys:
return i
return None return None
def trace_message_is_empty(message: dict[str, Any]) -> bool:
traces = message.get("traces")
if isinstance(traces, list):
has_trace = any(isinstance(trace, str) and trace.strip() for trace in traces)
else:
has_trace = bool(str(message.get("content") or "").strip())
return (
message.get("kind") == "trace"
and not has_trace
and not message.get("toolEvents")
and not message.get("fileEdits")
and not message.get("media")
)
def strip_covered_file_edit_tool_hints_from_recent_messages(
edits: list[dict[str, Any]],
turn_fields: dict[str, Any],
) -> None:
nonlocal messages
if not edits:
return
next_messages = list(messages)
changed = False
for i in range(len(next_messages) - 1, -1, -1):
candidate = next_messages[i]
if candidate.get("role") == "user":
break
if candidate.get("kind") != "trace":
continue
if not _same_turn(candidate, turn_fields):
continue
cleaned = _strip_covered_file_edit_tool_hints(candidate, edits)
if cleaned is candidate:
continue
changed = True
if trace_message_is_empty(cleaned):
next_messages.pop(i)
else:
next_messages[i] = cleaned
if changed:
messages = next_messages
def upsert_file_edits( def upsert_file_edits(
edits: list[dict[str, Any]], edits: list[dict[str, Any]],
idx: int, idx: int,
@@ -1549,12 +1583,12 @@ def replay_transcript_to_ui_messages(
segment = _new_activity_segment(activate=False) segment = _new_activity_segment(activate=False)
active_file_edit_segment_id = segment active_file_edit_segment_id = segment
demote_interrupted_assistant(segment) demote_interrupted_assistant(segment)
strip_covered_file_edit_tool_hints_from_recent_messages(edits, turn_fields)
target_index = find_file_edit_trace_index(segment, edits) target_index = find_file_edit_trace_index(segment, edits)
if target_index is not None: if target_index is not None:
last = messages[target_index] last = messages[target_index]
segment = str(last.get("activitySegmentId") or segment or _new_activity_segment(activate=False)) segment = str(last.get("activitySegmentId") or segment or _new_activity_segment(activate=False))
active_file_edit_segment_id = segment active_file_edit_segment_id = segment
last = _strip_covered_file_edit_tool_hints(last, edits)
else: else:
if not segment: if not segment:
segment = _new_activity_segment(activate=False) segment = _new_activity_segment(activate=False)
+53
View File
@@ -73,6 +73,9 @@ async def test_composite_fans_out_all_async_methods():
async def emit_reasoning(self, reasoning_content: str | None) -> None: async def emit_reasoning(self, reasoning_content: str | None) -> None:
events.append(f"emit_reasoning:{reasoning_content}") events.append(f"emit_reasoning:{reasoning_content}")
async def emit_reasoning_end(self) -> None:
events.append("emit_reasoning_end")
async def on_stream(self, context: AgentHookContext, delta: str) -> None: async def on_stream(self, context: AgentHookContext, delta: str) -> None:
events.append(f"on_stream:{delta}") events.append(f"on_stream:{delta}")
@@ -82,6 +85,15 @@ async def test_composite_fans_out_all_async_methods():
async def before_execute_tools(self, context: AgentHookContext) -> None: async def before_execute_tools(self, context: AgentHookContext) -> None:
events.append("before_execute_tools") events.append("before_execute_tools")
async def before_execute_tool(self, context, tool_call, tool, params) -> None:
events.append("before_execute_tool")
async def after_execute_tool(self, context, tool_call, tool, params, result) -> None:
events.append("after_execute_tool")
async def on_execute_tool_error(self, context, tool_call, tool, params, error) -> None:
events.append("on_execute_tool_error")
async def after_iteration(self, context: AgentHookContext) -> None: async def after_iteration(self, context: AgentHookContext) -> None:
events.append("after_iteration") events.append("after_iteration")
@@ -101,9 +113,13 @@ async def test_composite_fans_out_all_async_methods():
await hook.before_run(run_ctx) await hook.before_run(run_ctx)
await hook.before_iteration(ctx) await hook.before_iteration(ctx)
await hook.emit_reasoning("thinking...") await hook.emit_reasoning("thinking...")
await hook.emit_reasoning_end()
await hook.on_stream(ctx, "hi") await hook.on_stream(ctx, "hi")
await hook.on_stream_end(ctx, resuming=True) await hook.on_stream_end(ctx, resuming=True)
await hook.before_execute_tools(ctx) await hook.before_execute_tools(ctx)
await hook.before_execute_tool(ctx, object(), object(), {})
await hook.after_execute_tool(ctx, object(), object(), {}, "ok")
await hook.on_execute_tool_error(ctx, object(), object(), {}, "err")
await hook.after_iteration(ctx) await hook.after_iteration(ctx)
await hook.after_run(run_ctx) await hook.after_run(run_ctx)
await hook.on_error(run_ctx) await hook.on_error(run_ctx)
@@ -113,9 +129,13 @@ async def test_composite_fans_out_all_async_methods():
"before_run", "before_run", "before_run", "before_run",
"before_iteration", "before_iteration", "before_iteration", "before_iteration",
"emit_reasoning:thinking...", "emit_reasoning:thinking...", "emit_reasoning:thinking...", "emit_reasoning:thinking...",
"emit_reasoning_end", "emit_reasoning_end",
"on_stream:hi", "on_stream:hi", "on_stream:hi", "on_stream:hi",
"on_stream_end:True", "on_stream_end:True", "on_stream_end:True", "on_stream_end:True",
"before_execute_tools", "before_execute_tools", "before_execute_tools", "before_execute_tools",
"before_execute_tool", "before_execute_tool",
"after_execute_tool", "after_execute_tool",
"on_execute_tool_error", "on_execute_tool_error",
"after_iteration", "after_iteration", "after_iteration", "after_iteration",
"after_run", "after_run", "after_run", "after_run",
"on_error", "on_error", "on_error", "on_error",
@@ -172,10 +192,20 @@ async def test_composite_error_isolation_all_async():
raise RuntimeError("err") raise RuntimeError("err")
async def emit_reasoning(self, reasoning_content): async def emit_reasoning(self, reasoning_content):
raise RuntimeError("err") raise RuntimeError("err")
async def emit_reasoning_end(self):
raise RuntimeError("err")
async def on_stream(self, context, delta):
raise RuntimeError("err")
async def on_stream_end(self, context, *, resuming): async def on_stream_end(self, context, *, resuming):
raise RuntimeError("err") raise RuntimeError("err")
async def before_execute_tools(self, context): async def before_execute_tools(self, context):
raise RuntimeError("err") raise RuntimeError("err")
async def before_execute_tool(self, context, tool_call, tool, params):
raise RuntimeError("err")
async def after_execute_tool(self, context, tool_call, tool, params, result):
raise RuntimeError("err")
async def on_execute_tool_error(self, context, tool_call, tool, params, error):
raise RuntimeError("err")
async def after_iteration(self, context): async def after_iteration(self, context):
raise RuntimeError("err") raise RuntimeError("err")
async def after_run(self, context): async def after_run(self, context):
@@ -190,10 +220,20 @@ async def test_composite_error_isolation_all_async():
calls.append("before_run") calls.append("before_run")
async def emit_reasoning(self, reasoning_content): async def emit_reasoning(self, reasoning_content):
calls.append("emit_reasoning") calls.append("emit_reasoning")
async def emit_reasoning_end(self):
calls.append("emit_reasoning_end")
async def on_stream(self, context, delta):
calls.append("on_stream")
async def on_stream_end(self, context, *, resuming): async def on_stream_end(self, context, *, resuming):
calls.append("on_stream_end") calls.append("on_stream_end")
async def before_execute_tools(self, context): async def before_execute_tools(self, context):
calls.append("before_execute_tools") calls.append("before_execute_tools")
async def before_execute_tool(self, context, tool_call, tool, params):
calls.append("before_execute_tool")
async def after_execute_tool(self, context, tool_call, tool, params, result):
calls.append("after_execute_tool")
async def on_execute_tool_error(self, context, tool_call, tool, params, error):
calls.append("on_execute_tool_error")
async def after_iteration(self, context): async def after_iteration(self, context):
calls.append("after_iteration") calls.append("after_iteration")
async def after_run(self, context): async def after_run(self, context):
@@ -208,8 +248,13 @@ async def test_composite_error_isolation_all_async():
run_ctx = _run_ctx() run_ctx = _run_ctx()
await hook.before_run(run_ctx) await hook.before_run(run_ctx)
await hook.emit_reasoning("test") await hook.emit_reasoning("test")
await hook.emit_reasoning_end()
await hook.on_stream(ctx, "delta")
await hook.on_stream_end(ctx, resuming=False) await hook.on_stream_end(ctx, resuming=False)
await hook.before_execute_tools(ctx) await hook.before_execute_tools(ctx)
await hook.before_execute_tool(ctx, object(), object(), {})
await hook.after_execute_tool(ctx, object(), object(), {}, "ok")
await hook.on_execute_tool_error(ctx, object(), object(), {}, "err")
await hook.after_iteration(ctx) await hook.after_iteration(ctx)
await hook.after_run(run_ctx) await hook.after_run(run_ctx)
await hook.on_error(run_ctx) await hook.on_error(run_ctx)
@@ -217,8 +262,13 @@ async def test_composite_error_isolation_all_async():
assert calls == [ assert calls == [
"before_run", "before_run",
"emit_reasoning", "emit_reasoning",
"emit_reasoning_end",
"on_stream",
"on_stream_end", "on_stream_end",
"before_execute_tools", "before_execute_tools",
"before_execute_tool",
"after_execute_tool",
"on_execute_tool_error",
"after_iteration", "after_iteration",
"after_run", "after_run",
"on_error", "on_error",
@@ -313,6 +363,9 @@ async def test_composite_empty_hooks_no_ops():
await hook.on_stream(ctx, "delta") await hook.on_stream(ctx, "delta")
await hook.on_stream_end(ctx, resuming=False) await hook.on_stream_end(ctx, resuming=False)
await hook.before_execute_tools(ctx) await hook.before_execute_tools(ctx)
await hook.before_execute_tool(ctx, object(), object(), {})
await hook.after_execute_tool(ctx, object(), object(), {}, None)
await hook.on_execute_tool_error(ctx, object(), object(), {}, "err")
await hook.after_iteration(ctx) await hook.after_iteration(ctx)
await hook.after_run(run_ctx) await hook.after_run(run_ctx)
await hook.on_error(run_ctx) await hook.on_error(run_ctx)
+44 -45
View File
@@ -6,8 +6,9 @@ from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
import nanobot.agent.runner as runner_module from nanobot.agent.hooks import create_file_edit_activity_hook
from nanobot.agent.loop import AgentLoop from nanobot.agent.loop import AgentLoop
from nanobot.agent.tools.filesystem import WriteFileTool
from nanobot.bus.events import InboundMessage from nanobot.bus.events import InboundMessage
from nanobot.bus.outbound_events import ( from nanobot.bus.outbound_events import (
GoalStatusEvent, GoalStatusEvent,
@@ -31,7 +32,13 @@ def _make_loop(tmp_path: Path) -> AgentLoop:
bus = MessageBus() bus = MessageBus()
provider = MagicMock() provider = MagicMock()
provider.get_default_model.return_value = "test-model" provider.get_default_model.return_value = "test-model"
return AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model") return AgentLoop(
bus=bus,
provider=provider,
workspace=tmp_path,
model="test-model",
hook_factories=[create_file_edit_activity_hook],
)
def _attach_webui_runtime_events(loop: AgentLoop, bus: MessageBus) -> None: def _attach_webui_runtime_events(loop: AgentLoop, bus: MessageBus) -> None:
@@ -122,15 +129,10 @@ class TestToolEventProgress:
]) ])
loop.provider.chat_with_retry = AsyncMock(side_effect=lambda *a, **kw: next(calls)) loop.provider.chat_with_retry = AsyncMock(side_effect=lambda *a, **kw: next(calls))
loop.tools.get_definitions = MagicMock(return_value=[]) loop.tools.get_definitions = MagicMock(return_value=[])
tool = WriteFileTool(workspace=tmp_path)
loop.tools.prepare_call = MagicMock( loop.tools.prepare_call = MagicMock(
return_value=(None, {"path": "foo.txt", "content": "new\nextra\n"}, None), return_value=(tool, {"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] = [] file_events: list[dict] = []
async def on_progress( async def on_progress(
@@ -154,14 +156,15 @@ class TestToolEventProgress:
"path": "foo.txt", "path": "foo.txt",
"absolute_path": (tmp_path / "foo.txt").resolve().as_posix(), "absolute_path": (tmp_path / "foo.txt").resolve().as_posix(),
"phase": "start", "phase": "start",
"added": 2, "added": 0,
"deleted": 1, "deleted": 0,
"approximate": True, "approximate": True,
"status": "editing", "status": "editing",
} }
assert file_events[1]["status"] == "done" assert file_events[1]["status"] == "done"
assert file_events[1]["approximate"] is False assert file_events[1]["approximate"] is False
assert (file_events[1]["added"], file_events[1]["deleted"]) == (2, 1) assert (file_events[1]["added"], file_events[1]["deleted"]) == (2, 1)
assert file_events[1]["diff"]["format"] == "unified"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_file_edit_snapshot_skipped_when_progress_callback_cannot_emit_file_edits( async def test_file_edit_snapshot_skipped_when_progress_callback_cannot_emit_file_edits(
@@ -172,6 +175,16 @@ class TestToolEventProgress:
loop = _make_loop(tmp_path) loop = _make_loop(tmp_path)
target = tmp_path / "foo.txt" target = tmp_path / "foo.txt"
target.write_text("old\n", encoding="utf-8") target.write_text("old\n", encoding="utf-8")
prepare_file_edit_trackers = MagicMock()
class ObservableWriteTool:
name = "write_file"
async def execute(self, path: str, content: str) -> str:
target.write_text(content, encoding="utf-8")
return "ok"
tool = ObservableWriteTool()
tool_call = ToolCallRequest( tool_call = ToolCallRequest(
id="call-write", id="call-write",
name="write_file", name="write_file",
@@ -184,17 +197,9 @@ class TestToolEventProgress:
loop.provider.chat_with_retry = AsyncMock(side_effect=lambda *a, **kw: next(calls)) loop.provider.chat_with_retry = AsyncMock(side_effect=lambda *a, **kw: next(calls))
loop.tools.get_definitions = MagicMock(return_value=[]) loop.tools.get_definitions = MagicMock(return_value=[])
loop.tools.prepare_call = MagicMock( loop.tools.prepare_call = MagicMock(
return_value=(None, {"path": "foo.txt", "content": "new\n"}, None), return_value=(tool, {"path": "foo.txt", "content": "new\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)
prepare_tracker = MagicMock(side_effect=AssertionError("unexpected file snapshot"))
monkeypatch.setattr(runner_module, "prepare_file_edit_tracker", prepare_tracker)
async def on_progress( async def on_progress(
content: str, content: str,
*, *,
@@ -203,11 +208,16 @@ class TestToolEventProgress:
) -> None: ) -> None:
pass pass
monkeypatch.setattr(
"nanobot.agent.hooks.file_edit_activity.prepare_file_edit_trackers",
prepare_file_edit_trackers,
)
final_content, _, _, _, _ = await loop._run_agent_loop([], on_progress=on_progress) final_content, _, _, _, _ = await loop._run_agent_loop([], on_progress=on_progress)
assert final_content == "Done" assert final_content == "Done"
assert target.read_text(encoding="utf-8") == "new\n" assert target.read_text(encoding="utf-8") == "new\n"
prepare_tracker.assert_not_called() prepare_file_edit_trackers.assert_not_called()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_exec_does_not_emit_file_edit_progress(self, tmp_path: Path) -> None: async def test_exec_does_not_emit_file_edit_progress(self, tmp_path: Path) -> None:
@@ -342,31 +352,18 @@ class TestToolEventProgress:
assert outbound.event.file_edit_events == edit_events assert outbound.event.file_edit_events == edit_events
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_goal_turn_keeps_live_file_edit_progress_for_webui(self, tmp_path: Path) -> None: async def test_goal_turn_keeps_file_edit_progress_for_webui(self, tmp_path: Path) -> None:
"""The /goal command rewrites the prompt but must not bypass WebUI file-edit progress.""" """The /goal command rewrites the prompt but must not bypass WebUI file-edit progress."""
bus = MessageBus() bus = MessageBus()
provider = MagicMock() provider = MagicMock()
provider.supports_progress_deltas = True provider.supports_progress_deltas = True
provider.get_default_model.return_value = "test-model" provider.get_default_model.return_value = "test-model"
call_count = 0 call_count = 0
target = tmp_path / "goal.txt"
async def chat_stream_with_retry(*, on_tool_call_delta=None, **kwargs): async def chat_stream_with_retry(**kwargs):
nonlocal call_count nonlocal call_count
call_count += 1 call_count += 1
if call_count == 1: if call_count == 1:
assert on_tool_call_delta is not None
await on_tool_call_delta({
"index": 0,
"call_id": "call-goal-write",
"name": "write_file",
"arguments_delta": '{"path":"goal.txt","content":"',
})
await on_tool_call_delta({
"index": 0,
"arguments_delta": "one\\ntwo\\nthree\\n",
})
await on_tool_call_delta({"index": 0, "arguments_delta": '"}'})
return LLMResponse( return LLMResponse(
content=None, content=None,
tool_calls=[ tool_calls=[
@@ -383,25 +380,26 @@ class TestToolEventProgress:
) )
return LLMResponse(content="Done", tool_calls=[], usage={}) return LLMResponse(content="Done", tool_calls=[], usage={})
async def execute(name: str, params: dict) -> str:
assert name == "write_file"
target.write_text(params["content"], encoding="utf-8")
return "ok"
provider.chat_stream_with_retry = chat_stream_with_retry provider.chat_stream_with_retry = chat_stream_with_retry
provider.chat_with_retry = AsyncMock() provider.chat_with_retry = AsyncMock()
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model") loop = AgentLoop(
bus=bus,
provider=provider,
workspace=tmp_path,
model="test-model",
hook_factories=[create_file_edit_activity_hook],
)
tool = WriteFileTool(workspace=tmp_path)
loop.tools.get_definitions = MagicMock(return_value=[ loop.tools.get_definitions = MagicMock(return_value=[
{"type": "function", "function": {"name": "write_file"}}, {"type": "function", "function": {"name": "write_file"}},
]) ])
loop.tools.prepare_call = MagicMock( loop.tools.prepare_call = MagicMock(
return_value=( return_value=(
None, tool,
{"path": "goal.txt", "content": "one\ntwo\nthree\n"}, {"path": "goal.txt", "content": "one\ntwo\nthree\n"},
None, None,
), ),
) )
loop.tools.execute = AsyncMock(side_effect=execute)
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign] loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
await loop._dispatch(InboundMessage( await loop._dispatch(InboundMessage(
@@ -425,13 +423,14 @@ class TestToolEventProgress:
assert any( assert any(
event["status"] == "editing" event["status"] == "editing"
and event["approximate"] and event["approximate"]
and event["added"] == 3 and event["added"] == 0
for event in edit_events for event in edit_events
) )
assert any( assert any(
event["status"] == "done" event["status"] == "done"
and not event["approximate"] and not event["approximate"]
and event["added"] == 3 and event["added"] == 3
and event.get("diff", {}).get("format") == "unified"
for event in edit_events for event in edit_events
) )
provider.chat_with_retry.assert_not_awaited() provider.chat_with_retry.assert_not_awaited()
+8
View File
@@ -47,6 +47,12 @@ async def test_runner_calls_hooks_in_order():
[tc.name for tc in context.tool_calls], [tc.name for tc in context.tool_calls],
)) ))
async def before_execute_tool(self, context, tool_call, tool, params) -> None:
events.append(("before_execute_tool", context.iteration, tool_call.name, params))
async def after_execute_tool(self, context, tool_call, tool, params, result) -> None:
events.append(("after_execute_tool", context.iteration, tool_call.name, result))
async def after_iteration(self, context: AgentHookContext) -> None: async def after_iteration(self, context: AgentHookContext) -> None:
events.append(( events.append((
"after_iteration", "after_iteration",
@@ -75,6 +81,8 @@ async def test_runner_calls_hooks_in_order():
assert events == [ assert events == [
("before_iteration", 0), ("before_iteration", 0),
("before_execute_tools", 0, ["list_dir"]), ("before_execute_tools", 0, ["list_dir"]),
("before_execute_tool", 0, "list_dir", {"path": "."}),
("after_execute_tool", 0, "list_dir", "tool result"),
( (
"after_iteration", "after_iteration",
0, 0,
+62 -76
View File
@@ -4,7 +4,9 @@ from unittest.mock import AsyncMock, MagicMock
import pytest import pytest
from nanobot.agent.hooks import FileEditActivityHook
from nanobot.agent.runner import AgentRunner, AgentRunSpec from nanobot.agent.runner import AgentRunner, AgentRunSpec
from nanobot.agent.tools.filesystem import EditFileTool, WriteFileTool
from nanobot.config.schema import AgentDefaults from nanobot.config.schema import AgentDefaults
from nanobot.providers.base import LLMResponse, ToolCallRequest from nanobot.providers.base import LLMResponse, ToolCallRequest
@@ -80,42 +82,30 @@ async def test_runner_streams_provider_progress_deltas_by_default():
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runner_streams_live_write_file_activity_from_tool_argument_deltas(tmp_path): async def test_runner_emits_write_file_diff_from_tool_execution_snapshots(tmp_path):
provider = MagicMock() provider = MagicMock()
provider.supports_progress_deltas = True provider.supports_progress_deltas = True
call_count = 0 call_count = 0
progress_events: list[dict] = [] progress_events: list[dict] = []
(tmp_path / "big.txt").write_text("old\n", encoding="utf-8")
async def progress_cb(content, *, file_edit_events=None, **kwargs): async def progress_cb(content, *, file_edit_events=None, **kwargs):
if file_edit_events: if file_edit_events:
progress_events.extend(file_edit_events) progress_events.extend(file_edit_events)
tool = WriteFileTool(workspace=tmp_path)
class Tools: class Tools:
def get_definitions(self): def get_definitions(self):
return [{"type": "function", "function": {"name": "write_file"}}] return [{"type": "function", "function": {"name": "write_file"}}]
def get(self, name): def prepare_call(self, name, params):
return None return tool, params, None
async def execute(self, name, params): async def chat_stream_with_retry(**kwargs):
assert name == "write_file"
assert any(event["approximate"] and event["added"] == 24 for event in progress_events)
target = tmp_path / params["path"]
target.write_text(params["content"], encoding="utf-8")
return "ok"
async def chat_stream_with_retry(*, on_tool_call_delta=None, **kwargs):
nonlocal call_count nonlocal call_count
call_count += 1 call_count += 1
if call_count == 1: if call_count == 1:
assert on_tool_call_delta is not None
await on_tool_call_delta({
"index": 0,
"call_id": "call-write",
"name": "write_file",
"arguments_delta": '{"path":"big.txt","content":"',
})
await on_tool_call_delta({"index": 0, "arguments_delta": "line\\n" * 24})
return LLMResponse( return LLMResponse(
content=None, content=None,
tool_calls=[ tool_calls=[
@@ -131,29 +121,37 @@ async def test_runner_streams_live_write_file_activity_from_tool_argument_deltas
provider.chat_stream_with_retry = chat_stream_with_retry provider.chat_stream_with_retry = chat_stream_with_retry
provider.chat_with_retry = AsyncMock() provider.chat_with_retry = AsyncMock()
tools = Tools()
runner = AgentRunner(provider) runner = AgentRunner(provider)
result = await runner.run(AgentRunSpec( result = await runner.run(AgentRunSpec(
initial_messages=[{"role": "user", "content": "write a large file"}], initial_messages=[{"role": "user", "content": "write a large file"}],
tools=Tools(), tools=tools,
model="test-model", model="test-model",
max_iterations=2, max_iterations=2,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
progress_callback=progress_cb, progress_callback=progress_cb,
workspace=tmp_path, workspace=tmp_path,
hook=FileEditActivityHook(on_progress=progress_cb, workspace=tmp_path),
)) ))
assert result.final_content == "done" assert result.final_content == "done"
assert any(event["approximate"] and event["added"] == 24 for event in progress_events) assert progress_events[0]["phase"] == "start"
assert progress_events[0]["added"] == 0
assert progress_events[0]["deleted"] == 0
assert any( assert any(
not event["approximate"] and event["phase"] == "end" and event["added"] == 24 not event["approximate"]
and event["phase"] == "end"
and event["added"] == 24
and event["deleted"] == 1
and event["diff"]["format"] == "unified"
for event in progress_events for event in progress_events
) )
provider.chat_with_retry.assert_not_awaited() provider.chat_with_retry.assert_not_awaited()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runner_streams_live_edit_file_activity_from_tool_argument_deltas(tmp_path): async def test_runner_emits_edit_file_diff_from_tool_execution_snapshots(tmp_path):
provider = MagicMock() provider = MagicMock()
provider.supports_progress_deltas = True provider.supports_progress_deltas = True
call_count = 0 call_count = 0
@@ -165,43 +163,19 @@ async def test_runner_streams_live_edit_file_activity_from_tool_argument_deltas(
if file_edit_events: if file_edit_events:
progress_events.extend(file_edit_events) progress_events.extend(file_edit_events)
tool = EditFileTool(workspace=tmp_path)
class Tools: class Tools:
def get_definitions(self): def get_definitions(self):
return [{"type": "function", "function": {"name": "edit_file"}}] return [{"type": "function", "function": {"name": "edit_file"}}]
def get(self, name): def prepare_call(self, name, params):
return None return tool, params, None
async def execute(self, name, params): async def chat_stream_with_retry(**kwargs):
assert name == "edit_file"
assert any(
event["tool"] == "edit_file"
and event["approximate"]
and event["added"] == 3
and event["deleted"] == 2
for event in progress_events
)
target.write_text(params["new_text"], encoding="utf-8")
return "ok"
async def chat_stream_with_retry(*, on_tool_call_delta=None, **kwargs):
nonlocal call_count nonlocal call_count
call_count += 1 call_count += 1
if call_count == 1: if call_count == 1:
assert on_tool_call_delta is not None
await on_tool_call_delta({
"index": 0,
"call_id": "call-edit",
"name": "edit_file",
"arguments_delta": (
'{"path":"notes.txt","old_text":"old\\nkeep\\n","new_text":"'
),
})
await on_tool_call_delta({
"index": 0,
"arguments_delta": "new\\nkeep\\nextra\\n",
})
await on_tool_call_delta({"index": 0, "arguments_delta": '"}'})
return LLMResponse( return LLMResponse(
content=None, content=None,
tool_calls=[ tool_calls=[
@@ -221,75 +195,87 @@ async def test_runner_streams_live_edit_file_activity_from_tool_argument_deltas(
provider.chat_stream_with_retry = chat_stream_with_retry provider.chat_stream_with_retry = chat_stream_with_retry
provider.chat_with_retry = AsyncMock() provider.chat_with_retry = AsyncMock()
tools = Tools()
runner = AgentRunner(provider) runner = AgentRunner(provider)
result = await runner.run(AgentRunSpec( result = await runner.run(AgentRunSpec(
initial_messages=[{"role": "user", "content": "edit a file"}], initial_messages=[{"role": "user", "content": "edit a file"}],
tools=Tools(), tools=tools,
model="test-model", model="test-model",
max_iterations=2, max_iterations=2,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
progress_callback=progress_cb, progress_callback=progress_cb,
workspace=tmp_path, workspace=tmp_path,
hook=FileEditActivityHook(on_progress=progress_cb, workspace=tmp_path),
)) ))
assert result.final_content == "done" assert result.final_content == "done"
assert any(
event["tool"] == "edit_file"
and event["approximate"]
and event["added"] == 3
and event["deleted"] == 2
for event in progress_events
)
assert any( assert any(
event["tool"] == "edit_file" event["tool"] == "edit_file"
and not event["approximate"] and not event["approximate"]
and event["phase"] == "end" and event["phase"] == "end"
and event["added"] == 2 and event["added"] == 2
and event["deleted"] == 1 and event["deleted"] == 1
and event["diff"]["format"] == "unified"
for event in progress_events for event in progress_events
) )
provider.chat_with_retry.assert_not_awaited() provider.chat_with_retry.assert_not_awaited()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_runner_marks_unfinished_live_write_file_activity_failed(tmp_path): async def test_runner_marks_file_edit_activity_failed_when_tool_errors(tmp_path):
provider = MagicMock() provider = MagicMock()
provider.supports_progress_deltas = True provider.supports_progress_deltas = True
call_count = 0
progress_events: list[dict] = [] progress_events: list[dict] = []
async def progress_cb(content, *, file_edit_events=None, **kwargs): async def progress_cb(content, *, file_edit_events=None, **kwargs):
if file_edit_events: if file_edit_events:
progress_events.extend(file_edit_events) progress_events.extend(file_edit_events)
async def chat_stream_with_retry(*, on_tool_call_delta=None, **kwargs): tool = WriteFileTool(workspace=tmp_path)
assert on_tool_call_delta is not None
await on_tool_call_delta({ class Tools:
"index": 0, def get_definitions(self):
"call_id": "call-write", return [{"type": "function", "function": {"name": "write_file"}}]
"name": "write_file",
"arguments_delta": '{"path":"aborted.txt","content":"partial\\n', def prepare_call(self, name, params):
}) return tool, params, None
return LLMResponse(content="stopped", tool_calls=[], finish_reason="stop", usage={})
async def chat_stream_with_retry(**kwargs):
nonlocal call_count
call_count += 1
if call_count == 1:
return LLMResponse(
content=None,
tool_calls=[
ToolCallRequest(
id="call-write",
name="write_file",
arguments={"path": "aborted.txt"},
)
],
usage={},
)
return LLMResponse(content="done", tool_calls=[], usage={})
provider.chat_stream_with_retry = chat_stream_with_retry provider.chat_stream_with_retry = chat_stream_with_retry
provider.chat_with_retry = AsyncMock() provider.chat_with_retry = AsyncMock()
tools = MagicMock() tools = Tools()
tools.get_definitions.return_value = [{"type": "function", "function": {"name": "write_file"}}]
tools.get.return_value = None
runner = AgentRunner(provider) runner = AgentRunner(provider)
result = await runner.run(AgentRunSpec( result = await runner.run(AgentRunSpec(
initial_messages=[{"role": "user", "content": "write a large file"}], initial_messages=[{"role": "user", "content": "write a file"}],
tools=tools, tools=tools,
model="test-model", model="test-model",
max_iterations=1, max_iterations=2,
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS, max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
progress_callback=progress_cb, progress_callback=progress_cb,
workspace=tmp_path, workspace=tmp_path,
hook=FileEditActivityHook(on_progress=progress_cb, workspace=tmp_path),
)) ))
assert result.final_content == "stopped" assert result.stop_reason == "completed"
assert progress_events[-1]["path"] == "aborted.txt" assert progress_events[-1]["path"] == "aborted.txt"
assert progress_events[-1]["phase"] == "error" assert progress_events[-1]["phase"] == "error"
assert progress_events[-1]["status"] == "error" assert progress_events[-1]["status"] == "error"
+99 -425
View File
@@ -1,13 +1,13 @@
from __future__ import annotations from __future__ import annotations
import asyncio
from pathlib import Path from pathlib import Path
from types import SimpleNamespace
from nanobot.agent.tools.apply_patch import ApplyPatchTool
from nanobot.agent.tools.filesystem import EditFileTool, WriteFileTool
from nanobot.utils.file_edit_events import ( from nanobot.utils.file_edit_events import (
StreamingFileEditTracker,
build_file_edit_end_event, build_file_edit_end_event,
build_file_edit_start_event, build_file_edit_start_event,
build_unified_diff_payload,
line_diff_stats, line_diff_stats,
prepare_file_edit_tracker, prepare_file_edit_tracker,
prepare_file_edit_trackers, prepare_file_edit_trackers,
@@ -15,6 +15,18 @@ from nanobot.utils.file_edit_events import (
) )
def _write_tool(workspace: Path) -> WriteFileTool:
return WriteFileTool(workspace=workspace)
def _edit_tool(workspace: Path) -> EditFileTool:
return EditFileTool(workspace=workspace)
def _patch_tool(workspace: Path) -> ApplyPatchTool:
return ApplyPatchTool(workspace=workspace)
def test_line_diff_stats_counts_replacements_insertions_and_deletions() -> None: 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") added, deleted = line_diff_stats("a\nb\nc\n", "a\nB\nc\nd\n")
assert (added, deleted) == (2, 1) assert (added, deleted) == (2, 1)
@@ -28,20 +40,20 @@ def test_line_diff_stats_counts_new_file_crlf_lines_once() -> None:
assert line_diff_stats("", "a\r\nb\r\n") == (2, 0) assert line_diff_stats("", "a\r\nb\r\n") == (2, 0)
def test_write_file_start_predicts_and_end_calibrates_exact_diff(tmp_path: Path) -> None: def test_write_file_start_tracks_snapshot_and_end_emits_exact_diff(tmp_path: Path) -> None:
target = tmp_path / "notes.txt" target = tmp_path / "notes.txt"
target.write_text("old\nkeep\n", encoding="utf-8") target.write_text("old\nkeep\n", encoding="utf-8")
params = {"path": "notes.txt", "content": "new\nkeep\nextra\n"} params = {"path": "notes.txt", "content": "new\nkeep\nextra\n"}
tracker = prepare_file_edit_tracker( tracker = prepare_file_edit_tracker(
call_id="call-write", call_id="call-write",
tool_name="write_file", tool_name="write_file",
tool=None, tool=_write_tool(tmp_path),
workspace=tmp_path, workspace=tmp_path,
params=params, params=params,
) )
assert tracker is not None assert tracker is not None
start = build_file_edit_start_event(tracker, params) start = build_file_edit_start_event(tracker)
assert start == { assert start == {
"version": 1, "version": 1,
"call_id": "call-write", "call_id": "call-write",
@@ -49,8 +61,8 @@ def test_write_file_start_predicts_and_end_calibrates_exact_diff(tmp_path: Path)
"path": "notes.txt", "path": "notes.txt",
"absolute_path": (tmp_path / "notes.txt").resolve().as_posix(), "absolute_path": (tmp_path / "notes.txt").resolve().as_posix(),
"phase": "start", "phase": "start",
"added": 2, "added": 0,
"deleted": 1, "deleted": 0,
"approximate": True, "approximate": True,
"status": "editing", "status": "editing",
} }
@@ -61,6 +73,31 @@ def test_write_file_start_predicts_and_end_calibrates_exact_diff(tmp_path: Path)
assert end["status"] == "done" assert end["status"] == "done"
assert end["approximate"] is False assert end["approximate"] is False
assert (end["added"], end["deleted"]) == (2, 1) assert (end["added"], end["deleted"]) == (2, 1)
assert end["diff"]["format"] == "unified"
assert "hunks" not in end["diff"]
diff_text = end["diff"]["text"]
assert "--- notes.txt" in diff_text
assert "+++ notes.txt" in diff_text
assert "@@ " in diff_text
assert "-old" in diff_text
assert "+new" in diff_text
assert "+extra" in diff_text
def test_unified_diff_payload_truncates_large_diffs() -> None:
before = "\n".join(f"old {i}" for i in range(12))
after = "\n".join(f"new {i}" for i in range(12))
diff = build_unified_diff_payload(before, after, context_lines=0, max_lines=5)
assert diff is not None
assert diff["truncated"] is True
assert "hunks" not in diff
body_lines = [
line for line in diff["text"].splitlines()
if line.startswith((" ", "+", "-")) and not line.startswith(("+++", "---"))
]
assert len(body_lines) == 5
def test_binary_file_is_reported_but_not_counted(tmp_path: Path) -> None: def test_binary_file_is_reported_but_not_counted(tmp_path: Path) -> None:
@@ -69,7 +106,7 @@ def test_binary_file_is_reported_but_not_counted(tmp_path: Path) -> None:
tracker = prepare_file_edit_tracker( tracker = prepare_file_edit_tracker(
call_id="call-bin", call_id="call-bin",
tool_name="edit_file", tool_name="edit_file",
tool=None, tool=_edit_tool(tmp_path),
workspace=tmp_path, workspace=tmp_path,
params={"path": "data.bin", "old_text": "before", "new_text": "after"}, params={"path": "data.bin", "old_text": "before", "new_text": "after"},
) )
@@ -80,6 +117,26 @@ def test_binary_file_is_reported_but_not_counted(tmp_path: Path) -> None:
event = build_file_edit_end_event(tracker) event = build_file_edit_end_event(tracker)
assert event["binary"] is True assert event["binary"] is True
assert (event["added"], event["deleted"]) == (0, 0) assert (event["added"], event["deleted"]) == (0, 0)
assert "diff" not in event
def test_binary_before_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="write_file",
tool=_write_tool(tmp_path),
workspace=tmp_path,
params={"path": "data.bin", "content": "after\n"},
)
assert tracker is not None
target.write_text("after\n", encoding="utf-8")
event = build_file_edit_end_event(tracker)
assert event["binary"] is True
assert (event["added"], event["deleted"]) == (0, 0)
assert "diff" not in event
def test_apply_patch_prepares_trackers_for_each_touched_file(tmp_path: Path) -> None: def test_apply_patch_prepares_trackers_for_each_touched_file(tmp_path: Path) -> None:
@@ -95,7 +152,7 @@ def test_apply_patch_prepares_trackers_for_each_touched_file(tmp_path: Path) ->
trackers = prepare_file_edit_trackers( trackers = prepare_file_edit_trackers(
call_id="call-patch", call_id="call-patch",
tool_name="apply_patch", tool_name="apply_patch",
tool=None, tool=_patch_tool(tmp_path),
workspace=tmp_path, workspace=tmp_path,
params={"edits": edits}, params={"edits": edits},
) )
@@ -108,10 +165,32 @@ def test_apply_patch_prepares_trackers_for_each_touched_file(tmp_path: Path) ->
(tmp_path / "src" / "new.py").write_text("fresh\n", encoding="utf-8") (tmp_path / "src" / "new.py").write_text("fresh\n", encoding="utf-8")
existing.write_text("new\nkeep\n", encoding="utf-8") existing.write_text("new\nkeep\n", encoding="utf-8")
events = [build_file_edit_end_event(tracker, {"edits": edits}) for tracker in trackers] events = [build_file_edit_end_event(tracker) for tracker in trackers]
by_path = {event["path"]: event for event in events} by_path = {event["path"]: event for event in events}
assert (by_path["src/new.py"]["added"], by_path["src/new.py"]["deleted"]) == (1, 0) assert (by_path["src/new.py"]["added"], by_path["src/new.py"]["deleted"]) == (1, 0)
assert (by_path["src/existing.py"]["added"], by_path["src/existing.py"]["deleted"]) == (1, 1) assert (by_path["src/existing.py"]["added"], by_path["src/existing.py"]["deleted"]) == (1, 1)
assert by_path["src/new.py"]["diff"]["format"] == "unified"
assert by_path["src/existing.py"]["diff"]["format"] == "unified"
def test_apply_patch_trackers_use_normalized_patch_paths(tmp_path: Path) -> None:
(tmp_path / "file.txt").write_text("old\n", encoding="utf-8")
trackers = prepare_file_edit_trackers(
call_id="call-patch",
tool_name="apply_patch",
tool=_patch_tool(tmp_path),
workspace=tmp_path,
params={
"edits": [
{"path": " file.txt ", "action": "replace", "old_text": "old", "new_text": "new"},
{"path": "bad\0.txt", "action": "add", "new_text": "ignored"},
],
},
)
assert [tracker.display_path for tracker in trackers] == ["file.txt"]
assert trackers[0].path == (tmp_path / "file.txt").resolve()
def test_apply_patch_dry_run_does_not_prepare_file_edit_trackers(tmp_path: Path) -> None: def test_apply_patch_dry_run_does_not_prepare_file_edit_trackers(tmp_path: Path) -> None:
@@ -120,7 +199,7 @@ def test_apply_patch_dry_run_does_not_prepare_file_edit_trackers(tmp_path: Path)
trackers = prepare_file_edit_trackers( trackers = prepare_file_edit_trackers(
call_id="call-patch", call_id="call-patch",
tool_name="apply_patch", tool_name="apply_patch",
tool=None, tool=_patch_tool(tmp_path),
workspace=tmp_path, workspace=tmp_path,
params={ params={
"dry_run": True, "dry_run": True,
@@ -133,429 +212,24 @@ def test_apply_patch_dry_run_does_not_prepare_file_edit_trackers(tmp_path: Path)
assert trackers == [] assert trackers == []
def test_oversized_write_file_end_uses_known_content_for_exact_count(tmp_path: Path) -> None: def test_oversized_file_is_reported_but_not_counted(tmp_path: Path) -> None:
target = tmp_path / "large.txt" target = tmp_path / "large.txt"
params = {"path": "large.txt", "content": "x" * (2 * 1024 * 1024 + 1)} params = {"path": "large.txt", "content": "x"}
tracker = prepare_file_edit_tracker( tracker = prepare_file_edit_tracker(
call_id="call-large", call_id="call-large",
tool_name="write_file", tool_name="write_file",
tool=None, tool=_write_tool(tmp_path),
workspace=tmp_path, workspace=tmp_path,
params=params, params=params,
) )
assert tracker is not None assert tracker is not None
target.write_text(params["content"], encoding="utf-8") target.write_text("x" * (2 * 1024 * 1024 + 1), encoding="utf-8")
event = build_file_edit_end_event(tracker, params) event = build_file_edit_end_event(tracker)
assert event.get("binary") is not True assert event["binary"] is True
assert event["added"] == 1 assert event["added"] == 0
assert event["deleted"] == 0 assert event["deleted"] == 0
assert "diff" not in event
def test_streaming_write_file_tracker_emits_live_line_counts(tmp_path: Path) -> None:
events: list[dict] = []
async def emit(batch: list[dict]) -> None:
events.extend(batch)
async def run() -> None:
tracker = StreamingFileEditTracker(workspace=tmp_path, tools={}, emit=emit)
await tracker.update({
"index": 0,
"call_id": "call-live",
"name": "write_file",
"arguments_delta": '{"path":"notes.md","content":"',
})
await tracker.update({
"index": 0,
"arguments_delta": "line\\n" * 24,
})
asyncio.run(run())
assert events[0] == {
"version": 1,
"call_id": "call-live",
"tool": "write_file",
"path": "notes.md",
"absolute_path": (tmp_path / "notes.md").resolve().as_posix(),
"phase": "start",
"added": 0,
"deleted": 0,
"approximate": True,
"status": "editing",
}
assert events[-1]["path"] == "notes.md"
assert events[-1]["status"] == "editing"
assert events[-1]["approximate"] is True
assert events[-1]["added"] == 24
assert events[-1]["deleted"] == 0
def test_streaming_apply_patch_tracker_emits_live_counts_per_file(tmp_path: Path) -> None:
(tmp_path / "src").mkdir()
(tmp_path / "src" / "existing.py").write_text("old\nkeep\n", encoding="utf-8")
events: list[dict] = []
async def emit(batch: list[dict]) -> None:
events.extend(batch)
async def run() -> None:
tracker = StreamingFileEditTracker(workspace=tmp_path, tools={}, emit=emit)
await tracker.update({
"index": 0,
"call_id": "call-patch",
"name": "apply_patch",
"arguments_delta": (
'{"edits":[{"path":"src/existing.py","action":"replace","old_text":"old","new_text":"new"}'
',{"path":"src/new.py","action":"add","new_text":"fresh"}]}'
),
})
asyncio.run(run())
by_path = {event["path"]: event for event in events}
assert by_path["src/existing.py"]["tool"] == "apply_patch"
assert by_path["src/existing.py"]["status"] == "editing"
assert by_path["src/existing.py"]["approximate"] is True
assert (by_path["src/existing.py"]["added"], by_path["src/existing.py"]["deleted"]) == (1, 1)
assert (by_path["src/new.py"]["added"], by_path["src/new.py"]["deleted"]) == (1, 0)
def test_streaming_apply_patch_tracker_skips_dry_run(tmp_path: Path) -> None:
events: list[dict] = []
async def emit(batch: list[dict]) -> None:
events.extend(batch)
async def run() -> None:
tracker = StreamingFileEditTracker(workspace=tmp_path, tools={}, emit=emit)
await tracker.update({
"index": 0,
"call_id": "call-patch",
"name": "apply_patch",
"arguments_delta": (
'{"dry_run":true,"edits":[{"path":"dry.md","action":"add","new_text":"preview"}]}'
),
})
asyncio.run(run())
assert events == []
def test_streaming_write_file_tracker_emits_pending_before_path(tmp_path: Path) -> None:
events: list[dict] = []
async def emit(batch: list[dict]) -> None:
events.extend(batch)
async def run() -> None:
tracker = StreamingFileEditTracker(workspace=tmp_path, tools={}, emit=emit)
await tracker.update({
"index": 0,
"call_id": "call-live",
"name": "write_file",
"arguments_delta": '{"content":"line\\n',
})
await tracker.update({
"index": 0,
"arguments_delta": 'more\\n","path":"late.md"',
})
asyncio.run(run())
assert events[0] == {
"version": 1,
"call_id": "call-live",
"tool": "write_file",
"path": "",
"phase": "start",
"added": 1,
"deleted": 0,
"approximate": True,
"status": "editing",
"pending": True,
}
assert events[-1]["path"] == "late.md"
assert events[-1].get("pending") is not True
assert events[-1]["added"] == 2
def test_streaming_write_file_tracker_flushes_small_pending_count(tmp_path: Path) -> None:
events: list[dict] = []
async def emit(batch: list[dict]) -> None:
events.extend(batch)
async def run() -> None:
tracker = StreamingFileEditTracker(workspace=tmp_path, tools={}, emit=emit)
await tracker.update({
"index": 0,
"call_id": "call-live",
"name": "write_file",
"arguments_delta": '{"path":"small.md","content":"one\\n',
})
await tracker.flush()
asyncio.run(run())
assert events
assert events[-1]["path"] == "small.md"
assert events[-1]["added"] == 1
def test_streaming_write_file_tracker_normalizes_crlf_line_counts(tmp_path: Path) -> None:
events: list[dict] = []
async def emit(batch: list[dict]) -> None:
events.extend(batch)
async def run() -> None:
tracker = StreamingFileEditTracker(workspace=tmp_path, tools={}, emit=emit)
await tracker.update({
"index": 0,
"call_id": "call-live",
"name": "write_file",
"arguments_delta": '{"path":"windows.txt","content":"one\\r\\ntwo\\r\\n',
})
await tracker.flush()
asyncio.run(run())
assert events[-1]["path"] == "windows.txt"
assert events[-1]["added"] == 2
def test_streaming_write_file_tracker_counts_unicode_escaped_newlines(tmp_path: Path) -> None:
events: list[dict] = []
async def emit(batch: list[dict]) -> None:
events.extend(batch)
async def run() -> None:
tracker = StreamingFileEditTracker(workspace=tmp_path, tools={}, emit=emit)
await tracker.update({
"index": 0,
"call_id": "call-live",
"name": "write_file",
"arguments_delta": '{"path":"unicode.txt","content":"one\\u000atwo',
})
await tracker.flush()
asyncio.run(run())
assert events[-1]["path"] == "unicode.txt"
assert events[-1]["added"] == 2
def test_streaming_edit_file_tracker_emits_live_line_counts(tmp_path: Path) -> None:
target = tmp_path / "notes.md"
target.write_text("old\nkeep\n", encoding="utf-8")
events: list[dict] = []
async def emit(batch: list[dict]) -> None:
events.extend(batch)
async def run() -> None:
tracker = StreamingFileEditTracker(workspace=tmp_path, tools={}, emit=emit)
await tracker.update({
"index": 0,
"call_id": "call-edit",
"name": "edit_file",
"arguments_delta": '{"path":"notes.md","old_text":"old\\nkeep","new_text":"',
})
await tracker.update({
"index": 0,
"arguments_delta": "new\\nkeep\\nextra\\n" * 8,
})
asyncio.run(run())
assert events[0] == {
"version": 1,
"call_id": "call-edit",
"tool": "edit_file",
"path": "notes.md",
"absolute_path": (tmp_path / "notes.md").resolve().as_posix(),
"phase": "start",
"added": 0,
"deleted": 2,
"approximate": True,
"status": "editing",
}
assert events[-1]["path"] == "notes.md"
assert events[-1]["status"] == "editing"
assert events[-1]["approximate"] is True
assert events[-1]["added"] == 24
assert events[-1]["deleted"] == 2
def test_streaming_tracker_applies_canonical_call_id_to_final_tool(tmp_path: Path) -> None:
events: list[dict] = []
async def emit(batch: list[dict]) -> None:
events.extend(batch)
async def run() -> None:
tracker = StreamingFileEditTracker(workspace=tmp_path, tools={}, emit=emit)
await tracker.update({
"index": 0,
"name": "write_file",
"arguments_delta": '{"path":"matched.md","content":"one\\n',
})
final = SimpleNamespace(
id="provider-final-id",
name="write_file",
arguments={"path": "matched.md", "content": "one\n"},
)
tracker.apply_final_call_ids([final])
assert final.id == "idx:0"
asyncio.run(run())
def test_streaming_tracker_does_not_remap_non_file_edit_final_tool(tmp_path: Path) -> None:
events: list[dict] = []
async def emit(batch: list[dict]) -> None:
events.extend(batch)
async def run() -> None:
tracker = StreamingFileEditTracker(workspace=tmp_path, tools={}, emit=emit)
await tracker.update({
"index": 0,
"name": "read_file",
"arguments_delta": '{"path":"matched.md"}',
})
await tracker.update({
"index": 1,
"name": "write_file",
"arguments_delta": '{"path":"matched.md","content":"one\\n',
})
read_final = SimpleNamespace(
id="read-unique",
name="read_file",
arguments={"path": "matched.md"},
)
write_final = SimpleNamespace(
id="write-final",
name="write_file",
arguments={"path": "matched.md", "content": "one\n"},
)
tracker.apply_final_call_ids([read_final, write_final])
assert read_final.id == "read-unique"
assert write_final.id == "idx:1"
asyncio.run(run())
def test_streaming_tracker_does_not_restore_duplicate_canonical_ids(tmp_path: Path) -> None:
events: list[dict] = []
async def emit(batch: list[dict]) -> None:
events.extend(batch)
async def run() -> None:
tracker = StreamingFileEditTracker(workspace=tmp_path, tools={}, emit=emit)
await tracker.update({
"index": 0,
"call_id": "call_dup",
"name": "write_file",
"arguments_delta": '{"path":"a.md","content":"one\\n"}',
})
await tracker.update({
"index": 1,
"call_id": "call_dup",
"name": "write_file",
"arguments_delta": '{"path":"b.md","content":"two\\n"}',
})
final_a = SimpleNamespace(
id="call_dup",
name="write_file",
arguments={"path": "a.md", "content": "one\n"},
)
final_b = SimpleNamespace(
id="call_unique",
name="write_file",
arguments={"path": "b.md", "content": "two\n"},
)
tracker.apply_final_call_ids([final_a, final_b])
assert final_a.id == "call_dup"
assert final_b.id == "call_unique"
asyncio.run(run())
def test_streaming_edit_file_tracker_flushes_small_pending_count(tmp_path: Path) -> None:
target = tmp_path / "small.py"
target.write_text("old\n", encoding="utf-8")
events: list[dict] = []
async def emit(batch: list[dict]) -> None:
events.extend(batch)
async def run() -> None:
tracker = StreamingFileEditTracker(workspace=tmp_path, tools={}, emit=emit)
await tracker.update({
"index": 0,
"call_id": "call-edit",
"name": "edit_file",
"arguments_delta": '{"path":"small.py","old_text":"old\\n","new_text":"new\\nextra',
})
await tracker.flush()
asyncio.run(run())
assert events
assert events[-1]["path"] == "small.py"
assert events[-1]["added"] == 2
assert events[-1]["deleted"] == 1
def test_streaming_write_file_tracker_errors_unmatched_live_edits(tmp_path: Path) -> None:
events: list[dict] = []
async def emit(batch: list[dict]) -> None:
events.extend(batch)
async def run() -> None:
tracker = StreamingFileEditTracker(workspace=tmp_path, tools={}, emit=emit)
await tracker.update({
"index": 0,
"call_id": "call-live",
"name": "write_file",
"arguments_delta": '{"path":"aborted.md","content":"one\\n',
})
await tracker.error_unmatched([], "Tool call did not complete.")
asyncio.run(run())
assert events[-1]["path"] == "aborted.md"
assert events[-1]["phase"] == "error"
assert events[-1]["status"] == "error"
def test_streaming_write_file_tracker_keeps_matched_final_tool_call(tmp_path: Path) -> None:
events: list[dict] = []
async def emit(batch: list[dict]) -> None:
events.extend(batch)
async def run() -> None:
tracker = StreamingFileEditTracker(workspace=tmp_path, tools={}, emit=emit)
await tracker.update({
"index": 0,
"call_id": "idx-only",
"name": "write_file",
"arguments_delta": '{"path":"matched.md","content":"one\\n',
})
await tracker.error_unmatched([
SimpleNamespace(
id="final-call",
name="write_file",
arguments={"path": "matched.md", "content": "one\n"},
)
], "Tool call did not complete.")
asyncio.run(run())
assert events
assert all(event["status"] == "editing" for event in events)
def test_untracked_tools_do_not_prepare_file_edit_tracker(tmp_path: Path) -> None: def test_untracked_tools_do_not_prepare_file_edit_tracker(tmp_path: Path) -> None:
+64
View File
@@ -941,6 +941,70 @@ def test_replay_file_edit_absorbs_matching_write_tool_event() -> None:
] ]
def test_replay_file_edit_stays_separate_from_mixed_tool_trace() -> None:
msgs = replay_transcript_to_ui_messages([
{
"event": "message",
"chat_id": "t-file",
"text": "",
"kind": "tool_hint",
"tool_events": [
{
"phase": "start",
"call_id": "call-read",
"name": "read_file",
"arguments": {"path": "quicksort.py"},
},
{
"phase": "start",
"call_id": "call-write",
"name": "write_file",
"arguments": {"path": "sorting/quicksort.py", "content": "def quicksort():\n"},
},
],
},
{
"event": "file_edit",
"chat_id": "t-file",
"edits": [
{
"version": 1,
"call_id": "call-write",
"tool": "write_file",
"path": "sorting/quicksort.py",
"phase": "end",
"added": 3,
"deleted": 0,
"approximate": False,
"status": "done",
},
],
},
])
assert len(msgs) == 2
assert msgs[0]["kind"] == "trace"
assert msgs[0]["traces"] == ['read_file({"path": "quicksort.py"})']
assert [event["name"] for event in msgs[0]["toolEvents"]] == ["read_file"]
assert "fileEdits" not in msgs[0]
assert msgs[1]["kind"] == "trace"
assert msgs[1]["traces"] == []
assert "toolEvents" not in msgs[1]
assert msgs[1]["fileEdits"] == [
{
"version": 1,
"call_id": "call-write",
"tool": "write_file",
"path": "sorting/quicksort.py",
"phase": "end",
"added": 3,
"deleted": 0,
"approximate": False,
"status": "done",
},
]
def test_replay_keeps_every_file_from_one_apply_patch_call() -> None: def test_replay_keeps_every_file_from_one_apply_patch_call() -> None:
msgs = replay_transcript_to_ui_messages([ msgs = replay_transcript_to_ui_messages([
{ {
+3
View File
@@ -13,6 +13,7 @@
"@radix-ui/react-tooltip": "^1.1.6", "@radix-ui/react-tooltip": "^1.1.6",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"diff": "^9.0.0",
"i18next": "^26.0.6", "i18next": "^26.0.6",
"lucide-react": "^0.469.0", "lucide-react": "^0.469.0",
"react": "^18.3.1", "react": "^18.3.1",
@@ -419,6 +420,8 @@
"devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="], "devlop": ["devlop@1.1.0", "", { "dependencies": { "dequal": "^2.0.0" } }, "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA=="],
"diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
"didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="], "didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
+1
View File
@@ -20,6 +20,7 @@
"@radix-ui/react-tooltip": "^1.1.6", "@radix-ui/react-tooltip": "^1.1.6",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"diff": "^9.0.0",
"i18next": "^26.0.6", "i18next": "^26.0.6",
"lucide-react": "^0.469.0", "lucide-react": "^0.469.0",
"react": "^18.3.1", "react": "^18.3.1",
+118 -153
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react"; import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
import { AlertCircle, ChevronRight, FileText, Loader2, X } from "lucide-react"; import { AlertCircle, ChevronRight, Loader2, X } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { CodeBlock } from "@/components/CodeBlock"; import { CodeBlock } from "@/components/CodeBlock";
@@ -24,11 +24,6 @@ type PreviewState =
| { status: "error"; message: string } | { status: "error"; message: string }
| { status: "ready"; payload: FilePreviewPayload }; | { status: "ready"; payload: FilePreviewPayload };
function supportsHoverCloseControl(): boolean {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") return false;
return window.matchMedia("(hover: hover) and (pointer: fine)").matches;
}
export function FilePreviewPanel({ export function FilePreviewPanel({
sessionKey, sessionKey,
path, path,
@@ -41,26 +36,12 @@ export function FilePreviewPanel({
const { t } = useTranslation(); const { t } = useTranslation();
const [state, setState] = useState<PreviewState>({ status: "loading" }); const [state, setState] = useState<PreviewState>({ status: "loading" });
const [entered, setEntered] = useState(false); const [entered, setEntered] = useState(false);
const [supportsHoverClose, setSupportsHoverClose] = useState(supportsHoverCloseControl);
useEffect(() => { useEffect(() => {
const frame = window.requestAnimationFrame(() => setEntered(true)); const frame = window.requestAnimationFrame(() => setEntered(true));
return () => window.cancelAnimationFrame(frame); return () => window.cancelAnimationFrame(frame);
}, []); }, []);
useEffect(() => {
if (typeof window.matchMedia !== "function") return undefined;
const query = window.matchMedia("(hover: hover) and (pointer: fine)");
const update = () => setSupportsHoverClose(query.matches);
update();
if (typeof query.addEventListener === "function") {
query.addEventListener("change", update);
return () => query.removeEventListener("change", update);
}
query.addListener(update);
return () => query.removeListener(update);
}, []);
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setState({ status: "loading" }); setState({ status: "loading" });
@@ -89,15 +70,28 @@ export function FilePreviewPanel({
const normalizedPreviewPath = previewPath.replace(/\\/g, "/"); const normalizedPreviewPath = previewPath.replace(/\\/g, "/");
const hasRootPrefix = normalizedPreviewPath.startsWith("/"); const hasRootPrefix = normalizedPreviewPath.startsWith("/");
const { name } = splitFilePath(displayPath); const { name } = splitFilePath(displayPath);
const breadcrumbs = useMemo( const fileName = name || displayPath;
const pathParts = useMemo(
() => normalizedPreviewPath.split("/").filter(Boolean), () => normalizedPreviewPath.split("/").filter(Boolean),
[normalizedPreviewPath], [normalizedPreviewPath],
); );
const compactBreadcrumbs = useMemo( const directoryParts = useMemo(
() => (breadcrumbs.length > 2 ? breadcrumbs.slice(-2) : breadcrumbs), () => (pathParts.length > 1 ? pathParts.slice(0, -1) : []),
[breadcrumbs], [pathParts],
); );
const hasCompactPrefix = breadcrumbs.length > compactBreadcrumbs.length; const breadcrumbParts = useMemo(
() => (directoryParts.length > 0 ? [...directoryParts, fileName] : [fileName]),
[directoryParts, fileName],
);
const compactBreadcrumbParts = useMemo(
() => (breadcrumbParts.length > 3 ? breadcrumbParts.slice(-3) : breadcrumbParts),
[breadcrumbParts],
);
const hasCompactPrefix = breadcrumbParts.length > compactBreadcrumbParts.length;
const breadcrumbTitle = `${hasRootPrefix ? "/" : ""}${[
...directoryParts,
fileName,
].join("/")}`;
return ( return (
<aside <aside
@@ -144,144 +138,115 @@ export function FilePreviewPanel({
</button> </button>
) : null} ) : null}
<div className="flex min-h-0 flex-1 flex-col"> <div className="flex min-h-0 flex-1 flex-col">
<div className="flex h-12 shrink-0 items-center gap-2 border-b border-border/60 px-3"> <div
{supportsHoverClose ? ( className="flex h-11 shrink-0 items-center gap-2 border-b border-border/60 px-3"
<div title={previewPath}
className={cn( >
"group inline-flex max-w-full min-w-0 items-center gap-2 rounded-[12px]", <nav
"bg-muted/70 px-2.5 py-1.5 text-sm font-medium", aria-label={t("filePreview.breadcrumb", { defaultValue: "File path" })}
)} className="flex min-w-0 flex-1 items-center overflow-hidden text-sm leading-5"
title={name || displayPath} title={breadcrumbTitle}
> data-testid="file-preview-breadcrumb"
<button >
type="button" {hasCompactPrefix ? (
onClick={onClose} <>
className={cn( <span className="shrink-0 text-muted-foreground/55">...</span>
"relative inline-flex h-5 w-5 shrink-0 items-center justify-center overflow-hidden rounded-full", <ChevronRight
"text-muted-foreground/75 transition-[background-color,color,opacity] duration-150 ease-out", className="mx-1 h-3.5 w-3.5 shrink-0 text-muted-foreground/35"
"group-hover:bg-foreground group-hover:text-background group-hover:opacity-100",
"group-focus-within:bg-foreground group-focus-within:text-background",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
aria-label={t("filePreview.close", { defaultValue: "Close file preview" })}
>
<FileText
className={cn(
"absolute h-4 w-4 transition-all duration-150 ease-out",
"opacity-100 group-hover:scale-75 group-hover:opacity-0",
"group-focus-within:scale-75 group-focus-within:opacity-0",
)}
aria-hidden aria-hidden
/> />
<X </>
className={cn( ) : hasRootPrefix ? (
"absolute h-3.5 w-3.5 scale-75 opacity-0 transition-all duration-150 ease-out", <>
"group-hover:scale-100 group-hover:opacity-100", <span className="shrink-0 text-muted-foreground/55">/</span>
"group-focus-within:scale-100 group-focus-within:opacity-100", <ChevronRight
)} className="mx-1 h-3.5 w-3.5 shrink-0 text-muted-foreground/35"
aria-hidden aria-hidden
/> />
</button> </>
<span className="min-w-0 truncate">{name || displayPath}</span> ) : null}
{compactBreadcrumbParts.map((part, index) => {
const isLast = index === compactBreadcrumbParts.length - 1;
return (
<span
key={`${part}-${index}`}
className="flex min-w-0 items-center overflow-hidden"
>
{index > 0 ? (
<ChevronRight
className="mx-1 h-3.5 w-3.5 shrink-0 text-muted-foreground/35"
aria-hidden
/>
) : null}
<span
className={cn(
"min-w-0 truncate rounded-[4px] px-1 py-0.5",
isLast
? "font-medium text-foreground"
: "max-w-[26vw] shrink text-muted-foreground/78",
)}
data-testid={isLast ? "file-preview-title" : undefined}
>
{part}
</span>
</span>
);
})}
</nav>
<button
type="button"
onClick={onClose}
className={cn(
"inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-md",
"text-muted-foreground transition-colors hover:bg-muted hover:text-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
)}
title={t("filePreview.close", { defaultValue: "Close file preview" })}
aria-label={t("filePreview.close", { defaultValue: "Close file preview" })}
data-testid="file-preview-close"
>
<X className="h-4 w-4" aria-hidden />
</button>
</div>
<div className="min-h-0 flex-1 overflow-auto">
{state.status === "loading" ? (
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
{t("filePreview.loading", { defaultValue: "Loading preview..." })}
</div>
) : state.status === "error" ? (
<div className="flex h-full items-center justify-center px-8 text-center text-sm text-muted-foreground">
<div className="max-w-sm">
<AlertCircle
className="mx-auto mb-3 h-5 w-5 text-muted-foreground/70"
aria-hidden
/>
<p>{state.message}</p>
</div>
</div> </div>
) : ( ) : (
<> <div className="min-h-full">
<button {state.payload.truncated ? (
type="button" <div className="mx-4 mt-3 rounded-md border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-200">
onClick={onClose} {t("filePreview.truncated", {
className={cn( defaultValue: "Preview is truncated because this file is large.",
"inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-full", })}
"text-muted-foreground transition-colors hover:bg-muted hover:text-foreground", </div>
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", ) : null}
)} <CodeBlock
aria-label={t("filePreview.close", { defaultValue: "Close file preview" })} language={state.payload.language}
> code={state.payload.content}
<X className="h-5 w-5" aria-hidden /> chrome="none"
</button> showLineNumbers
<span className="min-w-0 truncate text-sm font-medium"> wrapLongLines={false}
{name || displayPath} className="min-h-full"
</span> />
</> </div>
)} )}
</div> </div>
<div className="flex min-h-0 flex-1 flex-col">
<div
className={cn(
"flex min-h-10 shrink-0 items-center gap-1.5 overflow-hidden",
"border-b border-border/45 px-4 text-[13px] text-muted-foreground",
)}
title={previewPath}
>
<div className="flex min-w-0 items-center gap-1.5">
{hasCompactPrefix ? (
<span className="shrink-0 text-muted-foreground/55">...</span>
) : hasRootPrefix ? (
<span className="shrink-0 text-muted-foreground/55">/</span>
) : null}
{compactBreadcrumbs.length > 0 ? (
compactBreadcrumbs.map((part, index) => (
<span key={`${part}-${index}`} className="flex min-w-0 items-center gap-1.5">
{index > 0 || hasCompactPrefix || hasRootPrefix ? (
<ChevronRight
className="h-3 w-3 shrink-0 text-muted-foreground/40"
aria-hidden
/>
) : null}
<span
className={cn(
"min-w-0 truncate",
index === compactBreadcrumbs.length - 1
? "font-medium text-foreground"
: "max-w-[42vw] shrink text-muted-foreground/76",
)}
>
{part}
</span>
</span>
))
) : (
<span className="truncate">{previewPath}</span>
)}
</div>
</div>
<div className="min-h-0 flex-1 overflow-auto">
{state.status === "loading" ? (
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
{t("filePreview.loading", { defaultValue: "Loading preview..." })}
</div>
) : state.status === "error" ? (
<div className="flex h-full items-center justify-center px-8 text-center text-sm text-muted-foreground">
<div className="max-w-sm">
<AlertCircle className="mx-auto mb-3 h-5 w-5 text-muted-foreground/70" aria-hidden />
<p>{state.message}</p>
</div>
</div>
) : (
<div className="min-h-full">
{state.payload.truncated ? (
<div className="mx-4 mt-3 rounded-md border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-200">
{t("filePreview.truncated", {
defaultValue: "Preview is truncated because this file is large.",
})}
</div>
) : null}
<CodeBlock
language={state.payload.language}
code={state.payload.content}
chrome="none"
showLineNumbers
wrapLongLines={false}
className="min-h-full"
/>
</div>
)}
</div>
</div> </div>
</div> </div>
</div>
</aside> </aside>
); );
} }
+27 -33
View File
@@ -111,6 +111,14 @@ import {
} from "@/lib/api"; } from "@/lib/api";
import { notifyCliAppsChanged } from "@/lib/cli-app-events"; import { notifyCliAppsChanged } from "@/lib/cli-app-events";
import { copyTextToClipboard } from "@/lib/clipboard"; import { copyTextToClipboard } from "@/lib/clipboard";
import {
LOCAL_PREFS_STORAGE_KEY,
readLocalPreferences,
type FileEditDisplayMode,
type LocalActivityMode,
type LocalDensity,
type LocalPreferences,
} from "@/lib/local-preferences";
import { getHostApi } from "@/lib/runtime"; import { getHostApi } from "@/lib/runtime";
import { notifyMcpPresetsChanged } from "@/lib/mcp-preset-events"; import { notifyMcpPresetsChanged } from "@/lib/mcp-preset-events";
import { fmtDateTime, relativeTime } from "@/lib/format"; import { fmtDateTime, relativeTime } from "@/lib/format";
@@ -155,8 +163,6 @@ export type SettingsSectionKey =
| "runtime" | "runtime"
| "advanced"; | "advanced";
type LocalDensity = "comfortable" | "compact";
type LocalActivityMode = "auto" | "expanded";
type AppsKindFilter = "all" | "nanobot" | "cli" | "mcp"; type AppsKindFilter = "all" | "nanobot" | "cli" | "mcp";
type AutomationFilter = "all" | "active" | "paused" | "failed" | "system"; type AutomationFilter = "all" | "active" | "paused" | "failed" | "system";
type AutomationSort = "next" | "last" | "updated" | "name"; type AutomationSort = "next" | "last" | "updated" | "name";
@@ -166,13 +172,6 @@ type AppsCatalogItem =
| { id: string; kind: "cli"; app: CliAppInfo } | { id: string; kind: "cli"; app: CliAppInfo }
| { id: string; kind: "mcp"; preset: McpPresetInfo }; | { id: string; kind: "mcp"; preset: McpPresetInfo };
interface LocalPreferences {
density: LocalDensity;
activityMode: LocalActivityMode;
codeWrap: boolean;
brandLogos: boolean;
}
interface AgentSettingsDraft { interface AgentSettingsDraft {
model: string; model: string;
provider: string; provider: string;
@@ -259,14 +258,6 @@ interface CustomMcpForm {
toolTimeout: string; toolTimeout: string;
} }
const LOCAL_PREFS_STORAGE_KEY = "nanobot-webui.settings-preferences";
const DEFAULT_LOCAL_PREFS: LocalPreferences = {
density: "comfortable",
activityMode: "auto",
codeWrap: true,
brandLogos: false,
};
const OPENAI_API_TYPE_OPTIONS: Array<{ value: ProviderApiType; label: string }> = [ const OPENAI_API_TYPE_OPTIONS: Array<{ value: ProviderApiType; label: string }> = [
{ value: "auto", label: "Auto" }, { value: "auto", label: "Auto" },
{ value: "chat_completions", label: "Chat Completions" }, { value: "chat_completions", label: "Chat Completions" },
@@ -318,22 +309,6 @@ interface SettingsViewProps {
hostChromeInset?: boolean; hostChromeInset?: boolean;
} }
function readLocalPreferences(): LocalPreferences {
try {
const raw = window.localStorage.getItem(LOCAL_PREFS_STORAGE_KEY);
if (!raw) return DEFAULT_LOCAL_PREFS;
const parsed = JSON.parse(raw) as Partial<LocalPreferences>;
return {
density: parsed.density === "compact" ? "compact" : "comfortable",
activityMode: parsed.activityMode === "expanded" ? "expanded" : "auto",
codeWrap: parsed.codeWrap !== false,
brandLogos: parsed.brandLogos === true,
};
} catch {
return DEFAULT_LOCAL_PREFS;
}
}
function modelPresetValue(payload: SettingsPayload): string { function modelPresetValue(payload: SettingsPayload): string {
return payload.agent.model_preset || "default"; return payload.agent.model_preset || "default";
} }
@@ -2337,6 +2312,25 @@ function AppearanceSettings({
} }
/> />
</SettingsRow> </SettingsRow>
<SettingsRow
title={tx("settings.rows.fileEditDisplay", "File edit display")}
description={tx("settings.help.fileEditDisplay", "Choose whether file edit activity opens as line counts or a diff.")}
>
<SegmentedControl
value={localPrefs.fileEditDisplayMode}
options={[
{ value: "summary", label: tx("settings.values.summary", "Summary") },
{ value: "diff", label: tx("settings.values.diff", "Diff") },
{ value: "collapsed_diff", label: tx("settings.values.collapsedDiff", "Collapsed diff") },
]}
onChange={(fileEditDisplayMode) =>
onChangeLocalPrefs((prev) => ({
...prev,
fileEditDisplayMode: fileEditDisplayMode as FileEditDisplayMode,
}))
}
/>
</SettingsRow>
<SettingsRow <SettingsRow
title={tx("settings.rows.codeWrap", "Code wrapping")} title={tx("settings.rows.codeWrap", "Code wrapping")}
description={tx("settings.help.codeWrap", "Keep long code lines readable on smaller screens.")} description={tx("settings.help.codeWrap", "Keep long code lines readable on smaller screens.")}
@@ -29,6 +29,9 @@ import {
isReasoningOnlyAssistant, isReasoningOnlyAssistant,
type ActivityEvidence, type ActivityEvidence,
} from "@/lib/activity-timeline"; } from "@/lib/activity-timeline";
import { useFileEditDisplayMode } from "@/hooks/useFileEditDisplayMode";
import { hasRenderableFileDiff } from "@/lib/file-diff";
import type { FileEditDisplayMode } from "@/lib/local-preferences";
import { faviconUrls, logoFallbackUrls } from "@/lib/provider-brand"; import { faviconUrls, logoFallbackUrls } from "@/lib/provider-brand";
import { formatToolCallTrace } from "@/lib/tool-traces"; import { formatToolCallTrace } from "@/lib/tool-traces";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -190,6 +193,7 @@ export function AgentActivityCluster({
onOpenFilePreview, onOpenFilePreview,
}: AgentActivityClusterProps) { }: AgentActivityClusterProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const fileEditDisplayMode = useFileEditDisplayMode();
const fileEdits = useMemo( const fileEdits = useMemo(
() => summarizeFileEdits(collectFileEdits(messages), isTurnStreaming), () => summarizeFileEdits(collectFileEdits(messages), isTurnStreaming),
[messages, isTurnStreaming], [messages, isTurnStreaming],
@@ -282,7 +286,7 @@ export function AgentActivityCluster({
}) })
: t(fileActivityManySummaryKey(hasLiveEditingFiles, hasFailedFiles, hasDeletedFiles), { : t(fileActivityManySummaryKey(hasLiveEditingFiles, hasFailedFiles, hasDeletedFiles), {
count: fileCount, count: fileCount,
defaultValue: `${fileActivityVerb(hasLiveEditingFiles, hasFailedFiles, hasDeletedFiles)} {{count}} files`, defaultValue: `${fileActivityVerb(hasLiveEditingFiles, hasFailedFiles, hasDeletedFiles)} {{count}} changes`,
}) })
: ""; : "";
@@ -438,6 +442,7 @@ export function AgentActivityCluster({
added={added} added={added}
deleted={deleted} deleted={deleted}
hasDiffStats={hasDiffStats} hasDiffStats={hasDiffStats}
fileEditDisplayMode={fileEditDisplayMode}
onOpenFilePreview={onOpenFilePreview} onOpenFilePreview={onOpenFilePreview}
/> />
); );
@@ -532,6 +537,7 @@ export function AgentActivityCluster({
{fileEdits.length ? ( {fileEdits.length ? (
<FileEditGroup <FileEditGroup
edits={fileEdits} edits={fileEdits}
displayMode={fileEditDisplayMode}
onOpenFilePreview={onOpenFilePreview} onOpenFilePreview={onOpenFilePreview}
/> />
) : null} ) : null}
@@ -561,6 +567,7 @@ function FileEditFlatActivity({
added, added,
deleted, deleted,
hasDiffStats, hasDiffStats,
fileEditDisplayMode,
onOpenFilePreview, onOpenFilePreview,
}: { }: {
edits: FileEditSummary[]; edits: FileEditSummary[];
@@ -575,9 +582,23 @@ function FileEditFlatActivity({
added: number; added: number;
deleted: number; deleted: number;
hasDiffStats: boolean; hasDiffStats: boolean;
fileEditDisplayMode: FileEditDisplayMode;
onOpenFilePreview?: (path: string) => void; onOpenFilePreview?: (path: string) => void;
}) { }) {
const showRows = edits.length > 1 || edits.some((edit) => edit.status === "error" || edit.pending); const diffOnlyRows = edits.length === 1
&& !!singleFilePath
&& fileEditDisplayMode !== "summary"
&& edits.some((edit) => (
edit.status !== "editing"
&& edit.status !== "error"
&& hasRenderableFileDiff(edit.diff)
));
const showRows = edits.length > 1
|| edits.some((edit) => edit.status === "error" || edit.pending)
|| (
fileEditDisplayMode !== "summary"
&& edits.some((edit) => hasRenderableFileDiff(edit.diff))
);
return ( return (
<div className={cn("w-full", hasBodyBelow && "mb-2")} aria-label={summary}> <div className={cn("w-full", hasBodyBelow && "mb-2")} aria-label={summary}>
<div <div
@@ -611,7 +632,12 @@ function FileEditFlatActivity({
</div> </div>
{showRows ? ( {showRows ? (
<div className="mt-0.5 pl-4"> <div className="mt-0.5 pl-4">
<FileEditGroup edits={edits} onOpenFilePreview={onOpenFilePreview} /> <FileEditGroup
edits={edits}
displayMode={fileEditDisplayMode}
density={diffOnlyRows ? "diff-only" : "default"}
onOpenFilePreview={onOpenFilePreview}
/>
</div> </div>
) : null} ) : null}
</div> </div>
@@ -1579,122 +1605,32 @@ function latestFileEditEvents(edits: UIFileEdit[]): UIFileEdit[] {
} }
function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSummary[] { function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSummary[] {
interface MutableSummary { return latestFileEditEvents(edits).flatMap((edit) => {
key: string; const editing = active && edit.status === "editing";
path: string; const failed = edit.status === "error";
absolute_path?: string; if (!edit.path && edit.pending && !editing) return [];
added: number; if (!edit.path && !editing && !failed) return [];
deleted: number;
approximate: boolean;
binary: boolean;
pending: boolean;
hasSuccessfulChange: boolean;
hasActiveEditing: boolean;
hasFailed: boolean;
operation?: UIFileEdit["operation"];
error?: string;
}
const order: string[] = []; const status: UIFileEdit["status"] = editing
const byPath = new Map<string, MutableSummary>();
for (const edit of latestFileEditEvents(edits)) {
const key = edit.path || edit.call_id || edit.tool;
let summary = byPath.get(key);
if (!summary) {
summary = {
key,
path: edit.path || "",
absolute_path: edit.absolute_path,
added: 0,
deleted: 0,
approximate: false,
binary: false,
pending: false,
hasSuccessfulChange: false,
hasActiveEditing: false,
hasFailed: false,
operation: undefined,
};
byPath.set(key, summary);
order.push(key);
}
if (edit.path && !summary.path) {
summary.path = edit.path;
}
if (edit.absolute_path) {
summary.absolute_path = edit.absolute_path;
}
if (edit.operation === "delete") {
summary.operation = "delete";
}
summary.pending = summary.pending || !!edit.pending || !edit.path;
if (!edit.path && edit.pending) {
if (active && edit.status === "editing") {
summary.hasActiveEditing = true;
summary.approximate = summary.approximate || !!edit.approximate;
if (!edit.binary) {
summary.added += edit.added;
summary.deleted += edit.deleted;
}
}
continue;
}
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.flatMap((key) => {
const summary = byPath.get(key)!;
if (
!summary.path
&& !summary.hasActiveEditing
&& !summary.hasSuccessfulChange
&& !summary.hasFailed
) {
return [];
}
const status: UIFileEdit["status"] = summary.hasActiveEditing
? "editing" ? "editing"
: summary.hasSuccessfulChange : failed
? "done" ? "error"
: summary.hasFailed : "done";
? "error" const binary = !!edit.binary;
: "done"; const diff = hasRenderableFileDiff(edit.diff) ? edit.diff : undefined;
return [{ return [{
key: summary.key, key: fileEditCallKey(edit),
path: summary.path, path: edit.path || "",
absolute_path: summary.absolute_path, absolute_path: edit.absolute_path,
added: summary.added, added: binary ? 0 : edit.added,
deleted: summary.deleted, deleted: binary ? 0 : edit.deleted,
approximate: summary.approximate, approximate: active && !!edit.approximate,
binary: summary.binary, binary,
status, status,
operation: summary.operation, operation: edit.operation,
pending: summary.pending && !summary.path, pending: !!edit.pending && !edit.path,
error: summary.error, error: edit.error,
diff,
}]; }];
}); });
} }
@@ -1,5 +1,3 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
export function DiffPair({ added, deleted }: { added: number; deleted: number }) { export function DiffPair({ added, deleted }: { added: number; deleted: number }) {
@@ -31,83 +29,7 @@ function DiffValue({ sign, value, className }: { sign: string; value: number; cl
> >
<span className="inline-flex items-baseline leading-none" aria-hidden> <span className="inline-flex items-baseline leading-none" aria-hidden>
{sign} {sign}
<AnimatedNumber value={safeValue} /> {safeValue}
</span>
<span className="sr-only">{sign}{safeValue}</span>
</span>
);
}
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 <RollingNumber value={display} />;
}
function RollingNumber({ value }: { value: number }) {
const digits = String(value).split("");
return (
<span className="inline-flex items-baseline leading-none" aria-hidden>
{digits.map((digit, index) => (
<RollingDigit
key={`${digits.length}-${index}`}
digit={Number(digit)}
/>
))}
</span>
);
}
function RollingDigit({ digit }: { digit: number }) {
const safeDigit = Number.isFinite(digit) ? Math.min(9, Math.max(0, digit)) : 0;
return (
<span className="relative inline-block h-[1em] w-[0.62em] overflow-hidden align-baseline leading-none">
<span className="invisible block h-[1em] leading-none">0</span>
<span
className="absolute inset-x-0 top-0 flex flex-col transition-transform duration-200 ease-out will-change-transform"
style={{ transform: `translateY(-${safeDigit}em)` }}
>
{Array.from({ length: 10 }, (_, n) => (
<span key={n} className="block h-[1em] leading-none">
{n}
</span>
))}
</span> </span>
</span> </span>
); );
@@ -1,13 +1,47 @@
import { AlertCircle, CheckCircle2, CircleDashed } from "lucide-react"; import { useEffect, useMemo, useState } from "react";
import {
AlertCircle,
CheckCircle2,
ChevronDown,
ChevronRight,
ChevronUp,
CircleDashed,
ExternalLink,
} from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { FileReferenceChip } from "@/components/FileReferenceChip"; import { FileReferenceChip } from "@/components/FileReferenceChip";
import type { UIFileEdit } from "@/lib/types"; import {
hasRenderableFileDiff,
parseRenderableFileDiff,
type RenderableFileDiff,
type RenderableFileDiffHunk,
type RenderableFileDiffLine,
} from "@/lib/file-diff";
import type { FileEditDisplayMode } from "@/lib/local-preferences";
import type { UIFileDiff, UIFileEdit } from "@/lib/types";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ActivityStep } from "./ActivityStep"; import { ActivityStep } from "./ActivityStep";
import { DiffPair } from "./DiffPair"; import { DiffPair } from "./DiffPair";
const INITIAL_VISIBLE_DIFF_LINES = 160;
const AUTO_COLLAPSE_DIFF_LINES = INITIAL_VISIBLE_DIFF_LINES;
type DiffFileEditDisplayMode = Exclude<FileEditDisplayMode, "summary">;
interface VisibleDiffHunk {
hunk: RenderableFileDiffHunk;
skippedBefore: number;
}
interface VisibleDiff {
hunks: VisibleDiffHunk[];
hiddenLineCount: number;
}
const EMPTY_VISIBLE_DIFF: VisibleDiff = { hunks: [], hiddenLineCount: 0 };
export interface FileEditSummary { export interface FileEditSummary {
key: string; key: string;
path: string; path: string;
@@ -20,40 +54,97 @@ export interface FileEditSummary {
operation?: UIFileEdit["operation"]; operation?: UIFileEdit["operation"];
pending: boolean; pending: boolean;
error?: string; error?: string;
diff?: UIFileDiff;
} }
export function FileEditGroup({ export function FileEditGroup({
edits, edits,
displayMode,
onOpenFilePreview, onOpenFilePreview,
density = "default",
}: { }: {
edits: FileEditSummary[]; edits: FileEditSummary[];
displayMode: FileEditDisplayMode;
onOpenFilePreview?: (path: string) => void; onOpenFilePreview?: (path: string) => void;
density?: "default" | "diff-only";
}) { }) {
if (edits.length === 0) return null; if (edits.length === 0) return null;
return ( return (
<ul className="space-y-1"> <ul className="space-y-1">
{edits.map((edit) => ( {edits.map((edit) => {
<FileEditRow if (density === "diff-only" && canRenderDiff(edit, displayMode)) {
key={edit.key} return (
edit={edit} <FileEditDiffOnly
onOpenFilePreview={onOpenFilePreview} key={edit.key}
/> edit={edit}
))} displayMode={displayMode}
onOpenFilePreview={onOpenFilePreview}
/>
);
}
return (
<FileEditRow
key={edit.key}
edit={edit}
displayMode={displayMode}
onOpenFilePreview={onOpenFilePreview}
/>
);
})}
</ul> </ul>
); );
} }
function canRenderDiff(
edit: FileEditSummary,
displayMode: FileEditDisplayMode,
): displayMode is DiffFileEditDisplayMode {
return (
displayMode !== "summary"
&& edit.status !== "editing"
&& edit.status !== "error"
&& hasRenderableFileDiff(edit.diff)
);
}
function FileEditDiffOnly({
edit,
displayMode,
onOpenFilePreview,
}: {
edit: FileEditSummary;
displayMode: DiffFileEditDisplayMode;
onOpenFilePreview?: (path: string) => void;
}) {
return (
<li className="min-w-0 py-0.5">
<FileUnifiedDiff
diff={edit.diff!}
collapsed={displayMode === "collapsed_diff"}
added={edit.added}
deleted={edit.deleted}
showCollapsedStats={false}
previewPath={edit.absolute_path || edit.path}
onOpenFilePreview={onOpenFilePreview}
/>
</li>
);
}
function FileEditRow({ function FileEditRow({
edit, edit,
displayMode,
onOpenFilePreview, onOpenFilePreview,
}: { }: {
edit: FileEditSummary; edit: FileEditSummary;
displayMode: FileEditDisplayMode;
onOpenFilePreview?: (path: string) => void; onOpenFilePreview?: (path: string) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const editing = edit.status === "editing"; const editing = edit.status === "editing";
const failed = edit.status === "error"; const failed = edit.status === "error";
const hasCountedDiff = !failed && !edit.binary && hasVisibleDiffStats(edit); const hasCountedDiff = !failed && !edit.binary && hasVisibleDiffStats(edit);
const showDiff = canRenderDiff(edit, displayMode);
const rawFailureDetail = failed ? cleanFileEditError(edit.error) : ""; const rawFailureDetail = failed ? cleanFileEditError(edit.error) : "";
const failureDetail = failed const failureDetail = failed
? formatFileEditError(edit.error) ? formatFileEditError(edit.error)
@@ -84,7 +175,7 @@ function FileEditRow({
active={editing} active={editing}
tone={failed ? "error" : editing ? "active" : "success"} tone={failed ? "error" : editing ? "active" : "success"}
className="text-xs" className="text-xs"
contentClassName={failed ? "min-w-0" : "grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3"} contentClassName={failed || showDiff ? "min-w-0" : "grid grid-cols-[minmax(0,1fr)_auto] items-center gap-3"}
title={rawFailureDetail || edit.absolute_path || edit.path} title={rawFailureDetail || edit.absolute_path || edit.path}
label={edit.pending && !edit.path label={edit.pending && !edit.path
? t("message.fileEditPreparing", { defaultValue: "Preparing file edit…" }) ? t("message.fileEditPreparing", { defaultValue: "Preparing file edit…" })
@@ -109,6 +200,16 @@ function FileEditRow({
{failureDetail} {failureDetail}
</span> </span>
) : null} ) : null}
{showDiff ? (
<FileUnifiedDiff
diff={edit.diff!}
collapsed={displayMode === "collapsed_diff"}
added={edit.added}
deleted={edit.deleted}
previewPath={edit.absolute_path || edit.path}
onOpenFilePreview={onOpenFilePreview}
/>
) : null}
</ActivityStep> </ActivityStep>
); );
} }
@@ -142,3 +243,279 @@ function formatFileEditError(error?: string): string {
.replace(/^path to (?:update|delete) is not a file: (.+)$/i, "Path is not a file: $1.") .replace(/^path to (?:update|delete) is not a file: (.+)$/i, "Path is not a file: $1.")
.slice(0, 180); .slice(0, 180);
} }
function FileUnifiedDiff({
diff,
collapsed,
added,
deleted,
showCollapsedStats = true,
previewPath,
onOpenFilePreview,
}: {
diff: UIFileDiff;
collapsed: boolean;
added: number;
deleted: number;
showCollapsedStats?: boolean;
previewPath?: string;
onOpenFilePreview?: (path: string) => void;
}) {
const { t } = useTranslation();
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
const [open, setOpen] = useState(false);
const [expandedLines, setExpandedLines] = useState(false);
const renderableDiff = useMemo(() => parseRenderableFileDiff(diff), [diff]);
const totalLineCount = useMemo(() => countDiffLines(renderableDiff), [renderableDiff]);
const shouldAutoCollapse = totalLineCount > AUTO_COLLAPSE_DIFF_LINES || !!diff.truncated;
const startsCollapsed = collapsed || shouldAutoCollapse;
const shouldRenderBody = !startsCollapsed || open;
const shouldLimitLines = totalLineCount > INITIAL_VISIBLE_DIFF_LINES;
const lineLimit = expandedLines || !shouldLimitLines
? totalLineCount
: INITIAL_VISIBLE_DIFF_LINES;
const visibleDiff = useMemo(
() => shouldRenderBody
? selectVisibleDiffLines(renderableDiff, lineLimit, totalLineCount)
: EMPTY_VISIBLE_DIFF,
[lineLimit, renderableDiff, shouldRenderBody, totalLineCount],
);
const lineCountLabel = t("message.fileEditDiffLineCount", {
count: diff.truncated ? `${totalLineCount}+` : totalLineCount,
defaultValue: "{{count}} lines",
});
const viewDiffLabel = shouldAutoCollapse
? tx("message.fileEditViewLargeDiff", "View large diff")
: tx("message.fileEditViewDiff", "View diff");
useEffect(() => {
setOpen(false);
setExpandedLines(false);
}, [diff]);
const handleToggleOpen = () => {
if (open) setExpandedLines(false);
setOpen(!open);
};
if (totalLineCount === 0) return null;
const renderBody = () => (
<div
className="mt-1 overflow-hidden rounded-md border border-border/55 bg-background/80 shadow-[0_1px_0_rgba(15,23,42,0.03)]"
data-testid="file-edit-diff"
>
{visibleDiff.hunks.map(({ hunk, skippedBefore }, index) => (
<div
key={`${hunk.old_start}-${hunk.new_start}-${index}`}
className={cn("min-w-0", index > 0 && "border-t border-border/45")}
>
{skippedBefore > 0 ? <DiffHunkGap lineCount={skippedBefore} /> : null}
<div className="overflow-x-auto">
<table className="w-full border-collapse font-mono text-[11px] leading-5">
<tbody>
{hunk.lines.map((line, lineIndex) => (
<DiffLineRow
key={`${line.old_lineno ?? ""}:${line.new_lineno ?? ""}:${lineIndex}`}
line={line}
/>
))}
</tbody>
</table>
</div>
</div>
))}
{visibleDiff.hiddenLineCount > 0 ? (
<div className="border-t border-border/45 bg-muted/30 px-2 py-1">
<button
type="button"
className={cn(
"inline-flex items-center gap-1 rounded px-1 py-0.5 text-[11px] font-medium",
"text-muted-foreground transition-colors hover:bg-muted/65 hover:text-foreground",
)}
data-testid="file-edit-diff-expand-lines"
onClick={() => setExpandedLines(true)}
>
<ChevronDown className="h-3 w-3" aria-hidden />
{t("message.fileEditShowMoreLines", {
count: visibleDiff.hiddenLineCount,
defaultValue: "Show {{count}} more lines",
})}
</button>
</div>
) : expandedLines && shouldLimitLines ? (
<div className="border-t border-border/45 bg-muted/30 px-2 py-1">
<button
type="button"
className={cn(
"inline-flex items-center gap-1 rounded px-1 py-0.5 text-[11px] font-medium",
"text-muted-foreground transition-colors hover:bg-muted/65 hover:text-foreground",
)}
data-testid="file-edit-diff-collapse-lines"
onClick={() => setExpandedLines(false)}
>
<ChevronUp className="h-3 w-3" aria-hidden />
{tx("message.fileEditShowFewerLines", "Show fewer lines")}
</button>
</div>
) : null}
{diff.truncated ? (
<div
className="flex flex-wrap items-center gap-x-2 gap-y-1 border-t border-border/45 bg-muted/35 px-2 py-1 text-[11px] text-muted-foreground"
data-testid="file-edit-diff-truncated"
>
<span>
{tx("message.fileEditDiffTruncated", "Diff truncated. Open the file for the full change.")}
</span>
{previewPath && onOpenFilePreview ? (
<button
type="button"
className={cn(
"inline-flex items-center gap-1 rounded px-1 py-0.5 font-medium",
"text-muted-foreground transition-colors hover:bg-muted/65 hover:text-foreground",
)}
data-testid="file-edit-diff-open-file"
onClick={() => onOpenFilePreview(previewPath)}
>
<ExternalLink className="h-3 w-3" aria-hidden />
{tx("message.fileEditOpenFile", "Open file")}
</button>
) : null}
</div>
) : null}
</div>
);
if (!startsCollapsed) return renderBody();
return (
<div className="mt-1">
<button
type="button"
aria-expanded={open}
data-testid="file-edit-diff-toggle"
onClick={handleToggleOpen}
className={cn(
"flex w-full cursor-pointer items-center gap-2 rounded-md border border-border/45 bg-muted/35 px-2 py-1 text-left",
"text-[11px] font-medium text-muted-foreground transition-colors hover:bg-muted/50",
)}
>
<ChevronRight
className={cn("h-3 w-3 shrink-0 transition-transform", open && "rotate-90")}
aria-hidden
/>
<span className="min-w-0 flex-1">{viewDiffLabel}</span>
<span className="shrink-0 text-muted-foreground/65">{lineCountLabel}</span>
{showCollapsedStats ? <DiffPair added={added} deleted={deleted} /> : null}
</button>
{open ? renderBody() : null}
</div>
);
}
function countDiffLines(diff: RenderableFileDiff): number {
return diff.hunks.reduce((total, hunk) => total + hunk.lines.length, 0);
}
function selectVisibleDiffLines(
diff: RenderableFileDiff,
lineLimit: number,
totalLineCount: number,
): VisibleDiff {
if (lineLimit >= totalLineCount) {
return {
hunks: diff.hunks.map((hunk, index) => ({
hunk,
skippedBefore: index > 0 ? countSkippedUnchangedLines(diff.hunks[index - 1], hunk) : 0,
})),
hiddenLineCount: 0,
};
}
let remaining = Math.max(0, lineLimit);
const hunks: VisibleDiffHunk[] = [];
let previousHunk: RenderableFileDiffHunk | null = null;
for (const hunk of diff.hunks) {
if (remaining <= 0) break;
const skippedBefore = previousHunk ? countSkippedUnchangedLines(previousHunk, hunk) : 0;
if (hunk.lines.length <= remaining) {
hunks.push({ hunk, skippedBefore });
remaining -= hunk.lines.length;
previousHunk = hunk;
continue;
}
hunks.push({ hunk: { ...hunk, lines: hunk.lines.slice(0, remaining) }, skippedBefore });
remaining = 0;
previousHunk = hunk;
}
return {
hunks,
hiddenLineCount: Math.max(0, totalLineCount - lineLimit),
};
}
function countSkippedUnchangedLines(
previous: RenderableFileDiffHunk,
current: RenderableFileDiffHunk,
): number {
const oldGap = current.old_start - (previous.old_start + previous.old_lines);
const newGap = current.new_start - (previous.new_start + previous.new_lines);
return Math.max(0, oldGap, newGap);
}
function DiffHunkGap({ lineCount }: { lineCount: number }) {
const { t } = useTranslation();
return (
<div
className="flex items-center gap-2 bg-muted/35 px-2 py-1 text-[11px] text-muted-foreground"
data-testid="file-edit-diff-hunk-gap"
>
<span
className="select-none rounded border border-border/45 bg-background/70 px-1 font-mono text-muted-foreground/70"
aria-hidden
>
...
</span>
<span>
{t("message.fileEditUnchangedLinesHidden", {
count: lineCount,
defaultValue: "{{count}} unchanged lines hidden",
})}
</span>
</div>
);
}
function DiffLineRow({ line }: { line: RenderableFileDiffLine }) {
const kind = line.kind === "add" || line.kind === "delete" ? line.kind : "context";
const marker = kind === "add" ? "+" : kind === "delete" ? "-" : " ";
return (
<tr
className={cn(
"border-0",
kind === "add" && "bg-emerald-500/[0.09] dark:bg-emerald-300/[0.11]",
kind === "delete" && "bg-rose-500/[0.09] dark:bg-rose-300/[0.11]",
)}
>
<td className="w-10 select-none border-r border-border/35 px-1.5 text-right text-muted-foreground/55">
{line.old_lineno ?? ""}
</td>
<td className="w-10 select-none border-r border-border/35 px-1.5 text-right text-muted-foreground/55">
{line.new_lineno ?? ""}
</td>
<td
className={cn(
"w-5 select-none px-1 text-center",
kind === "add" && "text-emerald-600/80 dark:text-emerald-300/85",
kind === "delete" && "text-rose-600/80 dark:text-rose-300/85",
kind === "context" && "text-muted-foreground/45",
)}
>
{marker}
</td>
<td className="min-w-[16rem] px-1.5 text-foreground/86">
<span className="whitespace-pre">{line.content || " "}</span>
</td>
</tr>
);
}
+21
View File
@@ -0,0 +1,21 @@
import { useEffect, useState } from "react";
import { readLocalPreferences, type FileEditDisplayMode } from "@/lib/local-preferences";
export function useFileEditDisplayMode(): FileEditDisplayMode {
const [mode, setMode] = useState<FileEditDisplayMode>(() =>
readLocalPreferences().fileEditDisplayMode,
);
useEffect(() => {
const refresh = () => setMode(readLocalPreferences().fileEditDisplayMode);
window.addEventListener("storage", refresh);
window.addEventListener("focus", refresh);
return () => {
window.removeEventListener("storage", refresh);
window.removeEventListener("focus", refresh);
};
}, []);
return mode;
}
+41 -8
View File
@@ -346,6 +346,44 @@ function stripCoveredFileEditToolHints(message: UIMessage, edits: UIFileEdit[]):
}; };
} }
function traceMessageIsEmpty(message: UIMessage): boolean {
const traces = message.traces;
const hasTrace = traces?.length
? traces.some((line) => line.trim().length > 0)
: (message.content ?? "").trim().length > 0;
return (
message.kind === "trace"
&& !hasTrace
&& !message.toolEvents?.length
&& !message.fileEdits?.length
&& !message.media?.length
);
}
function stripCoveredFileEditToolHintsFromMessages(
messages: UIMessage[],
edits: UIFileEdit[],
turn: UIMessageTurnFields,
): UIMessage[] {
if (edits.length === 0) return messages;
let next = messages;
for (let i = next.length - 1; i >= 0; i -= 1) {
const candidate = next[i];
if (candidate.role === "user") break;
if (candidate.kind !== "trace") continue;
if (!matchesTurn(candidate, turn)) continue;
const cleaned = stripCoveredFileEditToolHints(candidate, edits);
if (cleaned === candidate) continue;
if (next === messages) next = [...messages];
if (traceMessageIsEmpty(cleaned)) {
next.splice(i, 1);
} else {
next[i] = cleaned;
}
}
return next;
}
function normalizeFileEdit(edit: UIFileEdit): UIFileEdit | null { function normalizeFileEdit(edit: UIFileEdit): UIFileEdit | null {
if (!edit || !edit.tool || (!edit.path && !edit.pending)) return null; if (!edit || !edit.tool || (!edit.path && !edit.pending)) return null;
const inferredStatus = const inferredStatus =
@@ -417,10 +455,6 @@ function findFileEditTraceIndex(
) )
) return i; ) return i;
} }
for (const event of candidate.toolEvents ?? []) {
const key = toolEventFileEditKey(event);
if (key && incomingToolEventKeys.has(key)) return i;
}
} }
return null; return null;
} }
@@ -1040,16 +1074,15 @@ export function useNanobotStream(
} }
setMessages((prev) => { setMessages((prev) => {
let segmentId = eventSegmentId; let segmentId = eventSegmentId;
const base = prev; const base = stripCoveredFileEditToolHintsFromMessages(prev, normalized, turn);
const targetIndex = findFileEditTraceIndex(base, segmentId, normalized); const targetIndex = findFileEditTraceIndex(base, segmentId, normalized);
if (targetIndex !== null) { if (targetIndex !== null) {
const target = base[targetIndex]; const target = base[targetIndex];
segmentId = target.activitySegmentId ?? segmentId ?? detachedActivitySegmentId(); segmentId = target.activitySegmentId ?? segmentId ?? detachedActivitySegmentId();
if (opensFileEditPhase) fileEditSegmentRef.current = segmentId; if (opensFileEditPhase) fileEditSegmentRef.current = segmentId;
const cleanedTarget = stripCoveredFileEditToolHints(target, normalized);
const merged: UIMessage = { const merged: UIMessage = {
...cleanedTarget, ...target,
fileEdits: mergeFileEdits(cleanedTarget.fileEdits, normalized), fileEdits: mergeFileEdits(target.fileEdits, normalized),
activitySegmentId: segmentId, activitySegmentId: segmentId,
...turn, ...turn,
}; };
+14 -1
View File
@@ -141,6 +141,7 @@
"presetModel": "Preset model", "presetModel": "Preset model",
"density": "Density", "density": "Density",
"activityMode": "Activity detail", "activityMode": "Activity detail",
"fileEditDisplay": "File edit display",
"codeWrap": "Code wrapping", "codeWrap": "Code wrapping",
"brandLogos": "Brand logos", "brandLogos": "Brand logos",
"maxResults": "Max results", "maxResults": "Max results",
@@ -187,6 +188,7 @@
"presetModel": "Switch to Default to edit model and provider from the WebUI.", "presetModel": "Switch to Default to edit model and provider from the WebUI.",
"density": "Stored only in this browser.", "density": "Stored only in this browser.",
"activityMode": "Choose how much agent activity chrome to show by default.", "activityMode": "Choose how much agent activity chrome to show by default.",
"fileEditDisplay": "Choose whether file edit activity opens as line counts or a diff.",
"codeWrap": "Keep long code lines readable on smaller screens.", "codeWrap": "Keep long code lines readable on smaller screens.",
"brandLogos": "Show third-party provider and CLI logos in Settings.", "brandLogos": "Show third-party provider and CLI logos in Settings.",
"maxResults": "Results returned by each web_search call.", "maxResults": "Results returned by each web_search call.",
@@ -329,6 +331,9 @@
"compact": "Compact", "compact": "Compact",
"auto": "Auto", "auto": "Auto",
"expanded": "Expanded", "expanded": "Expanded",
"summary": "Summary",
"diff": "Diff",
"collapsedDiff": "Collapsed diff",
"on": "On", "on": "On",
"off": "Off", "off": "Off",
"defaultPermission": "Default Permission", "defaultPermission": "Default Permission",
@@ -1038,7 +1043,15 @@
"forkFromHere": "Fork", "forkFromHere": "Fork",
"copyReply": "Copy", "copyReply": "Copy",
"copiedReply": "Copied", "copiedReply": "Copied",
"turnLatencyTitle": "Response time (end-to-end)" "turnLatencyTitle": "Response time (end-to-end)",
"fileEditViewDiff": "View diff",
"fileEditViewLargeDiff": "View large diff",
"fileEditDiffLineCount": "{{count}} lines",
"fileEditUnchangedLinesHidden": "{{count}} unchanged lines hidden",
"fileEditShowMoreLines": "Show {{count}} more lines",
"fileEditShowFewerLines": "Show fewer lines",
"fileEditOpenFile": "Open file",
"fileEditDiffTruncated": "Diff truncated. Open the file for the full change."
}, },
"lightbox": { "lightbox": {
"title": "Image preview", "title": "Image preview",
+13
View File
@@ -121,6 +121,7 @@
"presetModel": "Modelo del preajuste", "presetModel": "Modelo del preajuste",
"density": "Densidad", "density": "Densidad",
"activityMode": "Detalle de actividad", "activityMode": "Detalle de actividad",
"fileEditDisplay": "Vista de edición de archivos",
"codeWrap": "Ajuste de código", "codeWrap": "Ajuste de código",
"maxResults": "Resultados máximos", "maxResults": "Resultados máximos",
"timeout": "Tiempo de espera", "timeout": "Tiempo de espera",
@@ -165,6 +166,7 @@
"presetModel": "Cambia a Default para editar modelo y proveedor desde WebUI.", "presetModel": "Cambia a Default para editar modelo y proveedor desde WebUI.",
"density": "Solo se guarda en este navegador.", "density": "Solo se guarda en este navegador.",
"activityMode": "Elige cuánto detalle de actividad del agente se muestra por defecto.", "activityMode": "Elige cuánto detalle de actividad del agente se muestra por defecto.",
"fileEditDisplay": "Elige si la actividad de edición muestra recuentos de líneas o el diff.",
"codeWrap": "Mantiene legibles las líneas largas de código en pantallas pequeñas.", "codeWrap": "Mantiene legibles las líneas largas de código en pantallas pequeñas.",
"maxResults": "Resultados devueltos por cada llamada web_search.", "maxResults": "Resultados devueltos por cada llamada web_search.",
"timeout": "Segundos antes de que una solicitud de búsqueda expire.", "timeout": "Segundos antes de que una solicitud de búsqueda expire.",
@@ -214,6 +216,9 @@
"compact": "Compacto", "compact": "Compacto",
"auto": "Automático", "auto": "Automático",
"expanded": "Expandido", "expanded": "Expandido",
"summary": "Resumen",
"diff": "Diff",
"collapsedDiff": "Diff contraído",
"on": "Activado", "on": "Activado",
"off": "Desactivado", "off": "Desactivado",
"defaultPermission": "Permiso predeterminado", "defaultPermission": "Permiso predeterminado",
@@ -1022,6 +1027,14 @@
"copyReply": "Copiar", "copyReply": "Copiar",
"copiedReply": "Copiado", "copiedReply": "Copiado",
"turnLatencyTitle": "Tiempo de respuesta (extremo a extremo)", "turnLatencyTitle": "Tiempo de respuesta (extremo a extremo)",
"fileEditViewDiff": "Ver diff",
"fileEditViewLargeDiff": "Ver diff grande",
"fileEditDiffLineCount": "{{count}} líneas",
"fileEditUnchangedLinesHidden": "{{count}} líneas sin cambios ocultas",
"fileEditShowMoreLines": "Mostrar {{count}} líneas más",
"fileEditShowFewerLines": "Mostrar menos líneas",
"fileEditOpenFile": "Abrir archivo",
"fileEditDiffTruncated": "Diff truncado. Abre el archivo para ver el cambio completo.",
"activityThinkingFor": "Pensando durante {{duration}}", "activityThinkingFor": "Pensando durante {{duration}}",
"activityThought": "Pensamiento completado", "activityThought": "Pensamiento completado",
"activityThoughtFor": "Pensó durante {{duration}}", "activityThoughtFor": "Pensó durante {{duration}}",
+13
View File
@@ -121,6 +121,7 @@
"presetModel": "Modèle du préréglage", "presetModel": "Modèle du préréglage",
"density": "Densité", "density": "Densité",
"activityMode": "Détail dactivité", "activityMode": "Détail dactivité",
"fileEditDisplay": "Affichage des modifications de fichiers",
"codeWrap": "Retour à la ligne du code", "codeWrap": "Retour à la ligne du code",
"maxResults": "Résultats max.", "maxResults": "Résultats max.",
"timeout": "Délai dattente", "timeout": "Délai dattente",
@@ -165,6 +166,7 @@
"presetModel": "Passez à Default pour modifier le modèle et le fournisseur depuis WebUI.", "presetModel": "Passez à Default pour modifier le modèle et le fournisseur depuis WebUI.",
"density": "Enregistré seulement dans ce navigateur.", "density": "Enregistré seulement dans ce navigateur.",
"activityMode": "Choisissez le niveau de détail dactivité agent affiché par défaut.", "activityMode": "Choisissez le niveau de détail dactivité agent affiché par défaut.",
"fileEditDisplay": "Choisissez si lactivité de modification affiche le nombre de lignes ou le diff.",
"codeWrap": "Garde les longues lignes de code lisibles sur les petits écrans.", "codeWrap": "Garde les longues lignes de code lisibles sur les petits écrans.",
"maxResults": "Résultats renvoyés par chaque appel web_search.", "maxResults": "Résultats renvoyés par chaque appel web_search.",
"timeout": "Nombre de secondes avant lexpiration dune requête de recherche.", "timeout": "Nombre de secondes avant lexpiration dune requête de recherche.",
@@ -214,6 +216,9 @@
"compact": "Compacte", "compact": "Compacte",
"auto": "Automatique", "auto": "Automatique",
"expanded": "Développé", "expanded": "Développé",
"summary": "Résumé",
"diff": "Diff",
"collapsedDiff": "Diff replié",
"on": "Activé", "on": "Activé",
"off": "Désactivé", "off": "Désactivé",
"defaultPermission": "Autorisation par défaut", "defaultPermission": "Autorisation par défaut",
@@ -1022,6 +1027,14 @@
"copyReply": "Copier", "copyReply": "Copier",
"copiedReply": "Copié", "copiedReply": "Copié",
"turnLatencyTitle": "Temps de réponse (de bout en bout)", "turnLatencyTitle": "Temps de réponse (de bout en bout)",
"fileEditViewDiff": "Voir le diff",
"fileEditViewLargeDiff": "Voir le grand diff",
"fileEditDiffLineCount": "{{count}} lignes",
"fileEditUnchangedLinesHidden": "{{count}} lignes inchangées masquées",
"fileEditShowMoreLines": "Afficher {{count}} lignes de plus",
"fileEditShowFewerLines": "Afficher moins de lignes",
"fileEditOpenFile": "Ouvrir le fichier",
"fileEditDiffTruncated": "Diff tronqué. Ouvrez le fichier pour voir la modification complète.",
"activityThinkingFor": "Réflexion pendant {{duration}}", "activityThinkingFor": "Réflexion pendant {{duration}}",
"activityThought": "Réflexion terminée", "activityThought": "Réflexion terminée",
"activityThoughtFor": "Réflexion terminée en {{duration}}", "activityThoughtFor": "Réflexion terminée en {{duration}}",
+13
View File
@@ -121,6 +121,7 @@
"presetModel": "Model preset", "presetModel": "Model preset",
"density": "Kerapatan", "density": "Kerapatan",
"activityMode": "Detail aktivitas", "activityMode": "Detail aktivitas",
"fileEditDisplay": "Tampilan edit file",
"codeWrap": "Bungkus kode", "codeWrap": "Bungkus kode",
"maxResults": "Hasil maksimum", "maxResults": "Hasil maksimum",
"timeout": "Batas waktu", "timeout": "Batas waktu",
@@ -165,6 +166,7 @@
"presetModel": "Beralih ke Default untuk mengedit model dan penyedia dari WebUI.", "presetModel": "Beralih ke Default untuk mengedit model dan penyedia dari WebUI.",
"density": "Hanya disimpan di browser ini.", "density": "Hanya disimpan di browser ini.",
"activityMode": "Pilih seberapa banyak detail aktivitas agen yang ditampilkan secara default.", "activityMode": "Pilih seberapa banyak detail aktivitas agen yang ditampilkan secara default.",
"fileEditDisplay": "Pilih aktivitas edit file dibuka sebagai jumlah baris atau diff.",
"codeWrap": "Menjaga baris kode panjang tetap terbaca di layar kecil.", "codeWrap": "Menjaga baris kode panjang tetap terbaca di layar kecil.",
"maxResults": "Resultados devueltos por cada llamada web_search.", "maxResults": "Resultados devueltos por cada llamada web_search.",
"timeout": "Segundos antes de que una solicitud de búsqueda expire.", "timeout": "Segundos antes de que una solicitud de búsqueda expire.",
@@ -214,6 +216,9 @@
"compact": "Ringkas", "compact": "Ringkas",
"auto": "Otomatis", "auto": "Otomatis",
"expanded": "Diperluas", "expanded": "Diperluas",
"summary": "Ringkasan",
"diff": "Diff",
"collapsedDiff": "Diff diciutkan",
"on": "Aktif", "on": "Aktif",
"off": "Nonaktif", "off": "Nonaktif",
"defaultPermission": "Izin default", "defaultPermission": "Izin default",
@@ -1022,6 +1027,14 @@
"copyReply": "Salin", "copyReply": "Salin",
"copiedReply": "Disalin", "copiedReply": "Disalin",
"turnLatencyTitle": "Waktu respons (ujung ke ujung)", "turnLatencyTitle": "Waktu respons (ujung ke ujung)",
"fileEditViewDiff": "Lihat diff",
"fileEditViewLargeDiff": "Lihat diff besar",
"fileEditDiffLineCount": "{{count}} baris",
"fileEditUnchangedLinesHidden": "{{count}} baris tidak berubah disembunyikan",
"fileEditShowMoreLines": "Tampilkan {{count}} baris lagi",
"fileEditShowFewerLines": "Tampilkan lebih sedikit baris",
"fileEditOpenFile": "Buka file",
"fileEditDiffTruncated": "Diff dipotong. Buka file untuk melihat perubahan lengkap.",
"activityThinkingFor": "Berpikir selama {{duration}}", "activityThinkingFor": "Berpikir selama {{duration}}",
"activityThought": "Selesai berpikir", "activityThought": "Selesai berpikir",
"activityThoughtFor": "Selesai berpikir dalam {{duration}}", "activityThoughtFor": "Selesai berpikir dalam {{duration}}",
+13
View File
@@ -121,6 +121,7 @@
"presetModel": "プリセットモデル", "presetModel": "プリセットモデル",
"density": "表示密度", "density": "表示密度",
"activityMode": "アクティビティ詳細", "activityMode": "アクティビティ詳細",
"fileEditDisplay": "ファイル編集表示",
"codeWrap": "コードの折り返し", "codeWrap": "コードの折り返し",
"maxResults": "最大結果数", "maxResults": "最大結果数",
"timeout": "タイムアウト", "timeout": "タイムアウト",
@@ -165,6 +166,7 @@
"presetModel": "Default に切り替えると、WebUI からモデルとプロバイダーを編集できます。", "presetModel": "Default に切り替えると、WebUI からモデルとプロバイダーを編集できます。",
"density": "このブラウザーにのみ保存されます。", "density": "このブラウザーにのみ保存されます。",
"activityMode": "既定で表示する agent アクティビティの詳細量を選択します。", "activityMode": "既定で表示する agent アクティビティの詳細量を選択します。",
"fileEditDisplay": "ファイル編集アクティビティを行数または差分で表示します。",
"codeWrap": "小さな画面でも長いコード行を読みやすくします。", "codeWrap": "小さな画面でも長いコード行を読みやすくします。",
"maxResults": "各 web_search 呼び出しで返す結果数です。", "maxResults": "各 web_search 呼び出しで返す結果数です。",
"timeout": "検索プロバイダーのリクエストがタイムアウトするまでの秒数です。", "timeout": "検索プロバイダーのリクエストがタイムアウトするまでの秒数です。",
@@ -214,6 +216,9 @@
"compact": "コンパクト", "compact": "コンパクト",
"auto": "自動", "auto": "自動",
"expanded": "展開", "expanded": "展開",
"summary": "概要",
"diff": "差分",
"collapsedDiff": "折りたたみ差分",
"on": "オン", "on": "オン",
"off": "オフ", "off": "オフ",
"defaultPermission": "既定の権限", "defaultPermission": "既定の権限",
@@ -1022,6 +1027,14 @@
"copyReply": "コピー", "copyReply": "コピー",
"copiedReply": "コピー済み", "copiedReply": "コピー済み",
"turnLatencyTitle": "応答時間(全行程)", "turnLatencyTitle": "応答時間(全行程)",
"fileEditViewDiff": "差分を表示",
"fileEditViewLargeDiff": "大きな差分を表示",
"fileEditDiffLineCount": "{{count}} 行",
"fileEditUnchangedLinesHidden": "未変更の {{count}} 行を非表示",
"fileEditShowMoreLines": "さらに {{count}} 行を表示",
"fileEditShowFewerLines": "表示行数を減らす",
"fileEditOpenFile": "ファイルを開く",
"fileEditDiffTruncated": "差分は切り詰められました。完全な変更はファイルを開いて確認してください。",
"activityThinkingFor": "{{duration}}考えています", "activityThinkingFor": "{{duration}}考えています",
"activityThought": "思考しました", "activityThought": "思考しました",
"activityThoughtFor": "{{duration}}考えました", "activityThoughtFor": "{{duration}}考えました",
+13
View File
@@ -121,6 +121,7 @@
"presetModel": "프리셋 모델", "presetModel": "프리셋 모델",
"density": "밀도", "density": "밀도",
"activityMode": "활동 상세", "activityMode": "활동 상세",
"fileEditDisplay": "파일 편집 표시",
"codeWrap": "코드 줄바꿈", "codeWrap": "코드 줄바꿈",
"maxResults": "최대 결과 수", "maxResults": "최대 결과 수",
"timeout": "타임아웃", "timeout": "타임아웃",
@@ -165,6 +166,7 @@
"presetModel": "Default로 전환하면 WebUI에서 모델과 제공자를 편집할 수 있습니다.", "presetModel": "Default로 전환하면 WebUI에서 모델과 제공자를 편집할 수 있습니다.",
"density": "이 브라우저에만 저장됩니다.", "density": "이 브라우저에만 저장됩니다.",
"activityMode": "기본으로 표시할 agent 활동 세부 수준을 선택합니다.", "activityMode": "기본으로 표시할 agent 활동 세부 수준을 선택합니다.",
"fileEditDisplay": "파일 편집 활동을 줄 수 또는 diff로 표시할지 선택합니다.",
"codeWrap": "작은 화면에서도 긴 코드 줄을 읽기 쉽게 유지합니다.", "codeWrap": "작은 화면에서도 긴 코드 줄을 읽기 쉽게 유지합니다.",
"maxResults": "각 web_search 호출에서 반환되는 결과 수입니다.", "maxResults": "각 web_search 호출에서 반환되는 결과 수입니다.",
"timeout": "검색 제공자 요청이 타임아웃되기 전의 초입니다.", "timeout": "검색 제공자 요청이 타임아웃되기 전의 초입니다.",
@@ -214,6 +216,9 @@
"compact": "컴팩트", "compact": "컴팩트",
"auto": "자동", "auto": "자동",
"expanded": "펼침", "expanded": "펼침",
"summary": "요약",
"diff": "Diff",
"collapsedDiff": "접힌 diff",
"on": "켜짐", "on": "켜짐",
"off": "꺼짐", "off": "꺼짐",
"defaultPermission": "기본 권한", "defaultPermission": "기본 권한",
@@ -1022,6 +1027,14 @@
"copyReply": "복사", "copyReply": "복사",
"copiedReply": "복사됨", "copiedReply": "복사됨",
"turnLatencyTitle": "응답 시간(엔드투엔드)", "turnLatencyTitle": "응답 시간(엔드투엔드)",
"fileEditViewDiff": "Diff 보기",
"fileEditViewLargeDiff": "큰 diff 보기",
"fileEditDiffLineCount": "{{count}}줄",
"fileEditUnchangedLinesHidden": "변경되지 않은 {{count}}줄 숨김",
"fileEditShowMoreLines": "{{count}}줄 더 보기",
"fileEditShowFewerLines": "줄 줄이기",
"fileEditOpenFile": "파일 열기",
"fileEditDiffTruncated": "Diff가 잘렸습니다. 전체 변경은 파일을 열어 확인하세요.",
"activityThinkingFor": "{{duration}} 동안 생각 중", "activityThinkingFor": "{{duration}} 동안 생각 중",
"activityThought": "생각함", "activityThought": "생각함",
"activityThoughtFor": "{{duration}} 동안 생각함", "activityThoughtFor": "{{duration}} 동안 생각함",
+13
View File
@@ -121,6 +121,7 @@
"presetModel": "Mô hình preset", "presetModel": "Mô hình preset",
"density": "Mật độ", "density": "Mật độ",
"activityMode": "Chi tiết hoạt động", "activityMode": "Chi tiết hoạt động",
"fileEditDisplay": "Hiển thị sửa tệp",
"codeWrap": "Xuống dòng mã", "codeWrap": "Xuống dòng mã",
"maxResults": "Kết quả tối đa", "maxResults": "Kết quả tối đa",
"timeout": "Thời gian chờ", "timeout": "Thời gian chờ",
@@ -165,6 +166,7 @@
"presetModel": "Chuyển sang Default để chỉnh sửa mô hình và nhà cung cấp từ WebUI.", "presetModel": "Chuyển sang Default để chỉnh sửa mô hình và nhà cung cấp từ WebUI.",
"density": "Chỉ lưu trong trình duyệt này.", "density": "Chỉ lưu trong trình duyệt này.",
"activityMode": "Chọn mức chi tiết hoạt động agent hiển thị mặc định.", "activityMode": "Chọn mức chi tiết hoạt động agent hiển thị mặc định.",
"fileEditDisplay": "Chọn hoạt động sửa tệp hiển thị số dòng hay diff.",
"codeWrap": "Giữ các dòng mã dài dễ đọc trên màn hình nhỏ.", "codeWrap": "Giữ các dòng mã dài dễ đọc trên màn hình nhỏ.",
"maxResults": "Resultados devueltos por cada llamada web_search.", "maxResults": "Resultados devueltos por cada llamada web_search.",
"timeout": "Segundos antes de que una solicitud de búsqueda expire.", "timeout": "Segundos antes de que una solicitud de búsqueda expire.",
@@ -214,6 +216,9 @@
"compact": "Gọn", "compact": "Gọn",
"auto": "Tự động", "auto": "Tự động",
"expanded": "Mở rộng", "expanded": "Mở rộng",
"summary": "Tóm tắt",
"diff": "Diff",
"collapsedDiff": "Diff thu gọn",
"on": "Bật", "on": "Bật",
"off": "Tắt", "off": "Tắt",
"defaultPermission": "Quyền mặc định", "defaultPermission": "Quyền mặc định",
@@ -1022,6 +1027,14 @@
"copyReply": "Sao chép", "copyReply": "Sao chép",
"copiedReply": "Đã sao chép", "copiedReply": "Đã sao chép",
"turnLatencyTitle": "Thời gian phản hồi (end-to-end)", "turnLatencyTitle": "Thời gian phản hồi (end-to-end)",
"fileEditViewDiff": "Xem diff",
"fileEditViewLargeDiff": "Xem diff lớn",
"fileEditDiffLineCount": "{{count}} dòng",
"fileEditUnchangedLinesHidden": "Đã ẩn {{count}} dòng không đổi",
"fileEditShowMoreLines": "Hiển thị thêm {{count}} dòng",
"fileEditShowFewerLines": "Hiển thị ít dòng hơn",
"fileEditOpenFile": "Mở tệp",
"fileEditDiffTruncated": "Diff đã bị cắt bớt. Mở tệp để xem toàn bộ thay đổi.",
"activityThinkingFor": "Đang suy nghĩ trong {{duration}}", "activityThinkingFor": "Đang suy nghĩ trong {{duration}}",
"activityThought": "Đã suy nghĩ", "activityThought": "Đã suy nghĩ",
"activityThoughtFor": "Đã suy nghĩ trong {{duration}}", "activityThoughtFor": "Đã suy nghĩ trong {{duration}}",
+14 -1
View File
@@ -141,6 +141,7 @@
"presetModel": "预设模型", "presetModel": "预设模型",
"density": "密度", "density": "密度",
"activityMode": "活动详情", "activityMode": "活动详情",
"fileEditDisplay": "文件编辑展示",
"codeWrap": "代码换行", "codeWrap": "代码换行",
"brandLogos": "品牌 Logo", "brandLogos": "品牌 Logo",
"maxResults": "最大结果数", "maxResults": "最大结果数",
@@ -187,6 +188,7 @@
"presetModel": "切回 Default 后可在 WebUI 中编辑模型和提供商。", "presetModel": "切回 Default 后可在 WebUI 中编辑模型和提供商。",
"density": "只保存在此浏览器中。", "density": "只保存在此浏览器中。",
"activityMode": "选择默认显示多少 agent 活动细节。", "activityMode": "选择默认显示多少 agent 活动细节。",
"fileEditDisplay": "选择文件编辑活动默认显示行数还是差异。",
"codeWrap": "让长代码行在小屏幕上也易读。", "codeWrap": "让长代码行在小屏幕上也易读。",
"brandLogos": "在设置中显示第三方提供商和 CLI 图标。", "brandLogos": "在设置中显示第三方提供商和 CLI 图标。",
"maxResults": "每次 web_search 调用返回的结果数。", "maxResults": "每次 web_search 调用返回的结果数。",
@@ -329,6 +331,9 @@
"compact": "紧凑", "compact": "紧凑",
"auto": "自动", "auto": "自动",
"expanded": "展开", "expanded": "展开",
"summary": "摘要",
"diff": "差异",
"collapsedDiff": "折叠差异",
"on": "开启", "on": "开启",
"off": "关闭", "off": "关闭",
"defaultPermission": "默认权限", "defaultPermission": "默认权限",
@@ -1038,7 +1043,15 @@
"forkFromHere": "分叉", "forkFromHere": "分叉",
"copyReply": "复制", "copyReply": "复制",
"copiedReply": "已复制", "copiedReply": "已复制",
"turnLatencyTitle": "本轮耗时(端到端)" "turnLatencyTitle": "本轮耗时(端到端)",
"fileEditViewDiff": "查看差异",
"fileEditViewLargeDiff": "查看大型差异",
"fileEditDiffLineCount": "{{count}} 行",
"fileEditUnchangedLinesHidden": "已隐藏 {{count}} 行未修改内容",
"fileEditShowMoreLines": "显示剩余 {{count}} 行",
"fileEditShowFewerLines": "收起部分行",
"fileEditOpenFile": "打开文件",
"fileEditDiffTruncated": "差异内容已截断。打开文件可查看完整更改。"
}, },
"lightbox": { "lightbox": {
"title": "图片预览", "title": "图片预览",
+13
View File
@@ -121,6 +121,7 @@
"presetModel": "預設模型", "presetModel": "預設模型",
"density": "密度", "density": "密度",
"activityMode": "活動細節", "activityMode": "活動細節",
"fileEditDisplay": "檔案編輯顯示",
"codeWrap": "程式碼換行", "codeWrap": "程式碼換行",
"maxResults": "最大結果數", "maxResults": "最大結果數",
"timeout": "逾時", "timeout": "逾時",
@@ -165,6 +166,7 @@
"presetModel": "切回 Default 後可在 WebUI 中編輯模型與供應商。", "presetModel": "切回 Default 後可在 WebUI 中編輯模型與供應商。",
"density": "只儲存在此瀏覽器中。", "density": "只儲存在此瀏覽器中。",
"activityMode": "選擇預設顯示多少 agent 活動細節。", "activityMode": "選擇預設顯示多少 agent 活動細節。",
"fileEditDisplay": "選擇檔案編輯活動預設顯示行數或差異。",
"codeWrap": "讓長程式碼行在小螢幕上也易讀。", "codeWrap": "讓長程式碼行在小螢幕上也易讀。",
"maxResults": "每次 web_search 呼叫返回的結果數。", "maxResults": "每次 web_search 呼叫返回的結果數。",
"timeout": "搜尋供應商請求逾時前的秒數。", "timeout": "搜尋供應商請求逾時前的秒數。",
@@ -214,6 +216,9 @@
"compact": "緊湊", "compact": "緊湊",
"auto": "自動", "auto": "自動",
"expanded": "展開", "expanded": "展開",
"summary": "摘要",
"diff": "差異",
"collapsedDiff": "摺疊差異",
"on": "開啟", "on": "開啟",
"off": "關閉", "off": "關閉",
"defaultPermission": "預設權限", "defaultPermission": "預設權限",
@@ -1022,6 +1027,14 @@
"copyReply": "複製", "copyReply": "複製",
"copiedReply": "已複製", "copiedReply": "已複製",
"turnLatencyTitle": "本輪耗時(端到端)", "turnLatencyTitle": "本輪耗時(端到端)",
"fileEditViewDiff": "查看差異",
"fileEditViewLargeDiff": "查看大型差異",
"fileEditDiffLineCount": "{{count}} 行",
"fileEditUnchangedLinesHidden": "已隱藏 {{count}} 行未修改內容",
"fileEditShowMoreLines": "顯示其餘 {{count}} 行",
"fileEditShowFewerLines": "收起部分行",
"fileEditOpenFile": "開啟檔案",
"fileEditDiffTruncated": "差異內容已截斷。開啟檔案可查看完整更改。",
"activityThinkingFor": "思考中,已 {{duration}}", "activityThinkingFor": "思考中,已 {{duration}}",
"activityThought": "已思考", "activityThought": "已思考",
"activityThoughtFor": "已思考 {{duration}}", "activityThoughtFor": "已思考 {{duration}}",
+94
View File
@@ -0,0 +1,94 @@
import { parsePatch } from "diff";
import type { UIFileDiff } from "@/lib/types";
export interface RenderableFileDiffLine {
kind: "context" | "add" | "delete";
old_lineno?: number | null;
new_lineno?: number | null;
content: string;
}
export interface RenderableFileDiffHunk {
old_start: number;
old_lines: number;
new_start: number;
new_lines: number;
lines: RenderableFileDiffLine[];
}
export interface RenderableFileDiff {
hunks: RenderableFileDiffHunk[];
}
export function hasRenderableFileDiff(diff?: UIFileDiff): boolean {
if (!diff) return false;
return typeof diff.text === "string" && diff.text.trim().length > 0;
}
export function parseRenderableFileDiff(diff: UIFileDiff): RenderableFileDiff {
if (typeof diff.text === "string" && diff.text.trim().length > 0) {
return parseUnifiedDiffText(diff.text);
}
return { hunks: [] };
}
function parseUnifiedDiffText(text: string): RenderableFileDiff {
let files: ReturnType<typeof parsePatch>;
try {
files = parsePatch(text);
} catch {
return { hunks: [] };
}
return {
hunks: files.flatMap((file) =>
file.hunks.map((hunk) => {
let oldLineno = hunk.oldStart;
let newLineno = hunk.newStart;
const lines: RenderableFileDiffLine[] = [];
for (const rawLine of hunk.lines) {
if (rawLine.startsWith("\\")) continue;
const marker = rawLine[0];
const content = rawLine.slice(1);
if (marker === "+") {
lines.push({
kind: "add",
old_lineno: null,
new_lineno: newLineno,
content,
});
newLineno += 1;
continue;
}
if (marker === "-") {
lines.push({
kind: "delete",
old_lineno: oldLineno,
new_lineno: null,
content,
});
oldLineno += 1;
continue;
}
lines.push({
kind: "context",
old_lineno: oldLineno,
new_lineno: newLineno,
content: marker === " " ? content : rawLine,
});
oldLineno += 1;
newLineno += 1;
}
return {
old_start: hunk.oldStart,
old_lines: hunk.oldLines,
new_start: hunk.newStart,
new_lines: hunk.newLines,
lines,
};
}),
),
};
}
+42
View File
@@ -0,0 +1,42 @@
export type LocalDensity = "comfortable" | "compact";
export type LocalActivityMode = "auto" | "expanded";
export type FileEditDisplayMode = "summary" | "diff" | "collapsed_diff";
export interface LocalPreferences {
density: LocalDensity;
activityMode: LocalActivityMode;
codeWrap: boolean;
brandLogos: boolean;
fileEditDisplayMode: FileEditDisplayMode;
}
export const LOCAL_PREFS_STORAGE_KEY = "nanobot-webui.settings-preferences";
export const DEFAULT_LOCAL_PREFS: LocalPreferences = {
density: "comfortable",
activityMode: "auto",
codeWrap: true,
brandLogos: false,
fileEditDisplayMode: "summary",
};
export function normalizeFileEditDisplayMode(value: unknown): FileEditDisplayMode {
return value === "diff" || value === "collapsed_diff" ? value : "summary";
}
export function readLocalPreferences(): LocalPreferences {
try {
const raw = window.localStorage.getItem(LOCAL_PREFS_STORAGE_KEY);
if (!raw) return DEFAULT_LOCAL_PREFS;
const parsed = JSON.parse(raw) as Partial<LocalPreferences>;
return {
density: parsed.density === "compact" ? "compact" : "comfortable",
activityMode: parsed.activityMode === "expanded" ? "expanded" : "auto",
codeWrap: parsed.codeWrap !== false,
brandLogos: parsed.brandLogos === true,
fileEditDisplayMode: normalizeFileEditDisplayMode(parsed.fileEditDisplayMode),
};
} catch {
return DEFAULT_LOCAL_PREFS;
}
}
+8
View File
@@ -210,6 +210,13 @@ export interface ToolProgressEvent {
embeds?: unknown[]; embeds?: unknown[];
} }
export interface UIFileDiff {
format: "unified" | string;
context?: number;
truncated?: boolean;
text?: string;
}
export interface UIFileEdit { export interface UIFileEdit {
version?: number; version?: number;
call_id: string; call_id: string;
@@ -225,6 +232,7 @@ export interface UIFileEdit {
binary?: boolean; binary?: boolean;
error?: string; error?: string;
pending?: boolean; pending?: boolean;
diff?: UIFileDiff;
} }
export interface ChatSummary { export interface ChatSummary {
+339 -13
View File
@@ -41,6 +41,15 @@ const BROWSERBASE_MCP: McpPresetInfo = {
connection_summary: "https://mcp.browserbase.com/mcp", connection_summary: "https://mcp.browserbase.com/mcp",
}; };
function unifiedFileDiff(lines: string[], truncated = false) {
return {
format: "unified" as const,
context: 3,
truncated,
text: lines.join("\n"),
};
}
function activityMessages(extraReasoning = "", extraTool?: UIMessage): UIMessage[] { function activityMessages(extraReasoning = "", extraTool?: UIMessage): UIMessage[] {
const rows: UIMessage[] = [ const rows: UIMessage[] = [
{ {
@@ -447,6 +456,301 @@ describe("AgentActivityCluster", () => {
} }
}); });
it("renders GitHub-like file edit diffs when the local preference is enabled", () => {
localStorage.setItem(
"nanobot-webui.settings-preferences",
JSON.stringify({ fileEditDisplayMode: "diff" }),
);
try {
render(
<AgentActivityCluster
messages={[{
id: "t-diff",
role: "tool",
kind: "trace",
content: "edit_file()",
traces: ["edit_file()"],
fileEdits: [{
call_id: "call-edit",
tool: "edit_file",
path: "src/app.tsx",
phase: "end",
added: 1,
deleted: 1,
approximate: false,
status: "done",
diff: unifiedFileDiff([
"--- src/app.tsx",
"+++ src/app.tsx",
"@@ -10,2 +10,2 @@",
" function App() {",
"- return <Old />;",
"+ return <New />;",
]),
}],
createdAt: 3,
}]}
isTurnStreaming={false}
hasBodyBelow={false}
/>,
);
expect(screen.getByTestId("file-edit-diff")).toBeInTheDocument();
expect(screen.queryByText("@@ -10,2 +10,2 @@")).not.toBeInTheDocument();
expect(screen.getByText("return <Old />;")).toBeInTheDocument();
expect(screen.getByText("return <New />;")).toBeInTheDocument();
expect(screen.getAllByText("11").length).toBeGreaterThanOrEqual(2);
expect(screen.getAllByTestId("activity-header-file-reference")).toHaveLength(1);
expect(screen.queryByTestId("activity-file-reference")).not.toBeInTheDocument();
expect(screen.getAllByTestId("activity-diff-pair")).toHaveLength(1);
} finally {
localStorage.removeItem("nanobot-webui.settings-preferences");
}
});
it("renders folded separators between separated file edit hunks", () => {
localStorage.setItem(
"nanobot-webui.settings-preferences",
JSON.stringify({ fileEditDisplayMode: "diff" }),
);
try {
render(
<AgentActivityCluster
messages={[{
id: "t-multi-hunk-diff",
role: "tool",
kind: "trace",
content: "edit_file()",
traces: ["edit_file()"],
fileEdits: [{
call_id: "call-multi-hunk-edit",
tool: "edit_file",
path: "src/app.tsx",
phase: "end",
added: 2,
deleted: 2,
approximate: false,
status: "done",
diff: unifiedFileDiff([
"--- src/app.tsx",
"+++ src/app.tsx",
"@@ -1,3 +1,3 @@",
" function first() {",
"- return oldFirst;",
"+ return newFirst;",
" }",
"@@ -25,3 +25,3 @@",
" function second() {",
"- return oldSecond;",
"+ return newSecond;",
" }",
]),
}],
createdAt: 3,
}]}
isTurnStreaming={false}
hasBodyBelow={false}
/>,
);
expect(screen.getByTestId("file-edit-diff-hunk-gap")).toHaveTextContent(
"21 unchanged lines hidden",
);
expect(screen.queryByText("@@ -25,3 +25,3 @@")).not.toBeInTheDocument();
expect(screen.getByText("return newSecond;")).toBeInTheDocument();
} finally {
localStorage.removeItem("nanobot-webui.settings-preferences");
}
});
it("keeps long file edit diffs collapsed until opened", () => {
localStorage.setItem(
"nanobot-webui.settings-preferences",
JSON.stringify({ fileEditDisplayMode: "diff" }),
);
const lines = Array.from({ length: 165 }, (_, index) => `line-${index + 1}`);
try {
render(
<AgentActivityCluster
messages={[{
id: "t-long-diff",
role: "tool",
kind: "trace",
content: "edit_file()",
traces: ["edit_file()"],
fileEdits: [{
call_id: "call-long-edit",
tool: "edit_file",
path: "src/long.ts",
phase: "end",
added: lines.length,
deleted: 0,
approximate: false,
status: "done",
diff: unifiedFileDiff([
"--- src/long.ts",
"+++ src/long.ts",
`@@ -0,0 +1,${lines.length} @@`,
...lines.map((line) => `+${line}`),
]),
}],
createdAt: 3,
}]}
isTurnStreaming={false}
hasBodyBelow={false}
/>,
);
const toggle = screen.getByTestId("file-edit-diff-toggle");
expect(toggle).toHaveAttribute("aria-expanded", "false");
expect(toggle).toHaveTextContent("View large diff");
expect(toggle).toHaveTextContent("165 lines");
expect(screen.queryByTestId("file-edit-diff")).not.toBeInTheDocument();
expect(screen.queryByText("line-1")).not.toBeInTheDocument();
fireEvent.click(toggle);
expect(toggle).toHaveAttribute("aria-expanded", "true");
expect(screen.getByText("line-160")).toBeInTheDocument();
expect(screen.queryByText("line-161")).not.toBeInTheDocument();
expect(screen.getByTestId("file-edit-diff-expand-lines")).toHaveTextContent("Show 5 more lines");
fireEvent.click(screen.getByTestId("file-edit-diff-expand-lines"));
expect(screen.getByText("line-165")).toBeInTheDocument();
expect(screen.getByTestId("file-edit-diff-collapse-lines")).toHaveTextContent("Show fewer lines");
fireEvent.click(screen.getByTestId("file-edit-diff-collapse-lines"));
expect(screen.queryByText("line-165")).not.toBeInTheDocument();
expect(screen.getByTestId("file-edit-diff-expand-lines")).toHaveTextContent("Show 5 more lines");
fireEvent.click(toggle);
expect(toggle).toHaveAttribute("aria-expanded", "false");
expect(screen.queryByTestId("file-edit-diff")).not.toBeInTheDocument();
} finally {
localStorage.removeItem("nanobot-webui.settings-preferences");
}
});
it("does not mount collapsed file edit diff rows until opened", () => {
localStorage.setItem(
"nanobot-webui.settings-preferences",
JSON.stringify({ fileEditDisplayMode: "collapsed_diff" }),
);
try {
render(
<AgentActivityCluster
messages={[{
id: "t-collapsed-diff",
role: "tool",
kind: "trace",
content: "edit_file()",
traces: ["edit_file()"],
fileEdits: [{
call_id: "call-collapsed-edit",
tool: "edit_file",
path: "src/app.tsx",
phase: "end",
added: 1,
deleted: 1,
approximate: false,
status: "done",
diff: unifiedFileDiff([
"--- src/app.tsx",
"+++ src/app.tsx",
"@@ -10,2 +10,2 @@",
" function App() {",
"- return <Old />;",
"+ return <New />;",
]),
}],
createdAt: 3,
}]}
isTurnStreaming={false}
hasBodyBelow={false}
/>,
);
const toggle = screen.getByTestId("file-edit-diff-toggle");
expect(toggle).toHaveAttribute("aria-expanded", "false");
expect(toggle).toHaveTextContent("View diff");
expect(toggle).toHaveTextContent("3 lines");
expect(screen.queryByTestId("file-edit-diff")).not.toBeInTheDocument();
expect(screen.queryByText("return <New />;")).not.toBeInTheDocument();
fireEvent.click(toggle);
expect(toggle).toHaveAttribute("aria-expanded", "true");
expect(screen.getByTestId("file-edit-diff")).toBeInTheDocument();
expect(screen.getByText("return <New />;")).toBeInTheDocument();
} finally {
localStorage.removeItem("nanobot-webui.settings-preferences");
}
});
it("offers the file preview entry point when a diff payload is truncated", () => {
localStorage.setItem(
"nanobot-webui.settings-preferences",
JSON.stringify({ fileEditDisplayMode: "diff" }),
);
const onOpenFilePreview = vi.fn();
try {
render(
<AgentActivityCluster
messages={[{
id: "t-truncated-diff",
role: "tool",
kind: "trace",
content: "edit_file()",
traces: ["edit_file()"],
fileEdits: [{
call_id: "call-truncated-edit",
tool: "edit_file",
path: "src/app.tsx",
absolute_path: "/repo/src/app.tsx",
phase: "end",
added: 1,
deleted: 0,
approximate: false,
status: "done",
diff: unifiedFileDiff([
"--- src/app.tsx",
"+++ src/app.tsx",
"@@ -9,0 +10,1 @@",
"+export const value = 1;",
], true),
}],
createdAt: 3,
}]}
isTurnStreaming={false}
hasBodyBelow={false}
onOpenFilePreview={onOpenFilePreview}
/>,
);
const toggle = screen.getByTestId("file-edit-diff-toggle");
expect(toggle).toHaveAttribute("aria-expanded", "false");
expect(toggle).toHaveTextContent("View large diff");
expect(screen.queryByTestId("file-edit-diff-truncated")).not.toBeInTheDocument();
fireEvent.click(toggle);
expect(screen.getByTestId("file-edit-diff-truncated")).toHaveTextContent("Diff truncated");
fireEvent.click(screen.getByTestId("file-edit-diff-open-file"));
expect(onOpenFilePreview).toHaveBeenCalledWith("/repo/src/app.tsx");
} finally {
localStorage.removeItem("nanobot-webui.settings-preferences");
}
});
it("labels whole-file deletes as deleted instead of edited", () => { it("labels whole-file deletes as deleted instead of edited", () => {
render( render(
<AgentActivityCluster <AgentActivityCluster
@@ -985,8 +1289,11 @@ describe("AgentActivityCluster", () => {
expect(screen.queryByText(/\[Errno 13\]/)).not.toBeInTheDocument(); expect(screen.queryByText(/\[Errno 13\]/)).not.toBeInTheDocument();
}); });
it("merges repeated edits for the same path and lets successful edits win over failures", async () => { it("renders repeated edits for the same path as separate actions", () => {
const restoreMotion = installReducedMotion(); localStorage.setItem(
"nanobot-webui.settings-preferences",
JSON.stringify({ fileEditDisplayMode: "diff" }),
);
try { try {
render( render(
<AgentActivityCluster <AgentActivityCluster
@@ -1006,6 +1313,13 @@ describe("AgentActivityCluster", () => {
deleted: 1, deleted: 1,
approximate: false, approximate: false,
status: "done", status: "done",
diff: unifiedFileDiff([
"--- minecraft-fps/index.html",
"+++ minecraft-fps/index.html",
"@@ -1,1 +1,2 @@",
" <main>",
"+ <canvas />",
]),
}, },
{ {
call_id: "call-edit-2", call_id: "call-edit-2",
@@ -1027,6 +1341,14 @@ describe("AgentActivityCluster", () => {
deleted: 6, deleted: 6,
approximate: false, approximate: false,
status: "done", status: "done",
diff: unifiedFileDiff([
"--- minecraft-fps/index.html",
"+++ minecraft-fps/index.html",
"@@ -8,2 +8,2 @@",
"-const fps = 30;",
"+const fps = 60;",
" start();",
]),
}, },
], ],
createdAt: 3, createdAt: 3,
@@ -1036,20 +1358,24 @@ describe("AgentActivityCluster", () => {
/>, />,
); );
expect(screen.getByRole("button", { name: /edited index\.html/i })).toBeInTheDocument(); const toggle = screen.getByRole("button", { name: "Edited 3 changes" });
expect(screen.queryByRole("button", { name: /failed index\.html/i })).not.toBeInTheDocument(); expect(toggle).toHaveTextContent("+8");
fireEvent.click(screen.getByRole("button", { name: /edited index\.html/i })); expect(toggle).toHaveTextContent("-7");
fireEvent.click(toggle);
const fileRefs = screen.getAllByTestId("activity-file-reference"); const fileRefs = screen.getAllByTestId("activity-file-reference");
expect(fileRefs).toHaveLength(1); expect(fileRefs).toHaveLength(3);
expect(fileRefs[0]).toHaveTextContent("minecraft-fps/index.html"); expect(fileRefs.every((ref) => ref.textContent?.includes("minecraft-fps/index.html"))).toBe(true);
expect(screen.queryByText("Failed")).not.toBeInTheDocument(); expect(screen.getByText("patch failed")).toBeInTheDocument();
await waitFor(() => { expect(screen.getAllByTestId("file-edit-diff")).toHaveLength(2);
expect(screen.getAllByText("+8").length).toBeGreaterThan(0); expect(screen.getByText("<canvas />")).toBeInTheDocument();
expect(screen.getAllByText("-7").length).toBeGreaterThan(0); expect(screen.getByText("const fps = 60;")).toBeInTheDocument();
}); expect(screen.getAllByText("+2").length).toBeGreaterThan(0);
expect(screen.getAllByText("-1").length).toBeGreaterThan(0);
expect(screen.getAllByText("+6").length).toBeGreaterThan(0);
expect(screen.getAllByText("-6").length).toBeGreaterThan(0);
} finally { } finally {
restoreMotion(); localStorage.removeItem("nanobot-webui.settings-preferences");
} }
}); });
@@ -0,0 +1,57 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { FilePreviewPanel } from "@/components/FilePreviewPanel";
import { fetchFilePreview } from "@/lib/api";
vi.mock("@/components/CodeBlock", () => ({
CodeBlock: ({ code }: { code: string }) => <pre data-testid="mock-code-block">{code}</pre>,
}));
vi.mock("@/lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/api")>();
return {
...actual,
fetchFilePreview: vi.fn(),
};
});
describe("FilePreviewPanel", () => {
beforeEach(() => {
vi.mocked(fetchFilePreview).mockReset();
});
it("shows a compact breadcrumb with one file name and a visible close action", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
vi.mocked(fetchFilePreview).mockResolvedValue({
path: "/Users/hr/workspace/quicksort.py",
display_path: "quicksort.py",
language: "python",
content: "print('ok')",
truncated: false,
});
render(
<FilePreviewPanel
sessionKey="websocket:chat-1"
path="quicksort.py"
token="tok"
onClose={onClose}
/>,
);
expect(await screen.findByTestId("mock-code-block")).toHaveTextContent("print('ok')");
expect(screen.getByTestId("file-preview-breadcrumb")).toHaveTextContent("...");
expect(screen.getByTestId("file-preview-breadcrumb")).toHaveTextContent("workspace");
expect(screen.getByTestId("file-preview-title")).toHaveTextContent("quicksort.py");
expect(screen.getAllByText("quicksort.py")).toHaveLength(1);
const closeButton = screen.getByRole("button", { name: "Close file preview" });
expect(closeButton).toBeVisible();
await user.click(closeButton);
expect(onClose).toHaveBeenCalledTimes(1);
});
});
+2
View File
@@ -81,6 +81,7 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [
"settings.rows.language", "settings.rows.language",
"settings.rows.density", "settings.rows.density",
"settings.rows.activityMode", "settings.rows.activityMode",
"settings.rows.fileEditDisplay",
"settings.rows.codeWrap", "settings.rows.codeWrap",
"settings.rows.brandLogos", "settings.rows.brandLogos",
"settings.rows.currentModel", "settings.rows.currentModel",
@@ -91,6 +92,7 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [
"settings.help.language", "settings.help.language",
"settings.help.density", "settings.help.density",
"settings.help.activityMode", "settings.help.activityMode",
"settings.help.fileEditDisplay",
"settings.help.codeWrap", "settings.help.codeWrap",
"settings.help.brandLogos", "settings.help.brandLogos",
"settings.help.currentModel", "settings.help.currentModel",
+18 -1
View File
@@ -159,7 +159,7 @@ const installedAnyGen = {
function renderSettingsView( function renderSettingsView(
options: { options: {
initialSection?: "overview" | "apps" | "automations" | "advanced" | "models" | "browser"; initialSection?: "overview" | "appearance" | "apps" | "automations" | "advanced" | "models" | "browser";
initialSettings?: SettingsPayload; initialSettings?: SettingsPayload;
showSidebar?: boolean; showSidebar?: boolean;
onSettingsChange?: (payload: SettingsPayload) => void; onSettingsChange?: (payload: SettingsPayload) => void;
@@ -185,10 +185,27 @@ function renderSettingsView(
describe("SettingsView Apps catalog", () => { describe("SettingsView Apps catalog", () => {
afterEach(() => { afterEach(() => {
localStorage.removeItem("nanobot-webui.settings-preferences");
vi.useRealTimers(); vi.useRealTimers();
vi.unstubAllGlobals(); vi.unstubAllGlobals();
}); });
it("persists the file edit display local preference", async () => {
renderSettingsView({
initialSection: "appearance",
initialSettings: settingsPayload(),
showSidebar: true,
});
expect(screen.getByText("File edit display")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Diff" }));
await waitFor(() => {
const saved = JSON.parse(localStorage.getItem("nanobot-webui.settings-preferences") || "{}");
expect(saved.fileEditDisplayMode).toBe("diff");
});
});
it("does not show the Settings kicker on the standalone Automations surface", async () => { it("does not show the Settings kicker on the standalone Automations surface", async () => {
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
const url = String(input); const url = String(input);
+65
View File
@@ -596,6 +596,71 @@ describe("useNanobotStream", () => {
expect(result.current.messages[0].toolEvents).toBeUndefined(); expect(result.current.messages[0].toolEvents).toBeUndefined();
}); });
it("keeps live file edits separate from mixed non-file tool traces", () => {
const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-file-edit-mixed-tools", EMPTY_MESSAGES), {
wrapper: wrap(fake.client),
});
act(() => {
fake.emit("chat-file-edit-mixed-tools", {
event: "message",
chat_id: "chat-file-edit-mixed-tools",
text: "",
kind: "tool_hint",
tool_events: [
{
phase: "start",
call_id: "call-read",
name: "read_file",
arguments: { path: "quicksort.py" },
},
{
phase: "start",
call_id: "call-write",
name: "write_file",
arguments: { path: "sorting/quicksort.py", content: "def quicksort():\n" },
},
],
});
fake.emit("chat-file-edit-mixed-tools", {
event: "file_edit",
chat_id: "chat-file-edit-mixed-tools",
edits: [{
call_id: "call-write",
tool: "write_file",
path: "sorting/quicksort.py",
phase: "end",
added: 3,
deleted: 0,
approximate: false,
status: "done",
}],
});
});
expect(result.current.messages).toHaveLength(2);
expect(result.current.messages[0]).toMatchObject({
role: "tool",
kind: "trace",
traces: ['read_file({"path":"quicksort.py"})'],
});
expect(result.current.messages[0].toolEvents?.map((event) => event.name)).toEqual(["read_file"]);
expect(result.current.messages[0].fileEdits).toBeUndefined();
expect(result.current.messages[1]).toMatchObject({
role: "tool",
kind: "trace",
traces: [],
fileEdits: [{
call_id: "call-write",
tool: "write_file",
path: "sorting/quicksort.py",
status: "done",
}],
});
expect(result.current.messages[1].toolEvents).toBeUndefined();
});
it("keeps every file from one apply_patch call", () => { it("keeps every file from one apply_patch call", () => {
const fake = fakeClient(); const fake = fakeClient();
const { result } = renderHook(() => useNanobotStream("chat-apply-patch-many", EMPTY_MESSAGES), { const { result } = renderHook(() => useNanobotStream("chat-apply-patch-many", EMPTY_MESSAGES), {