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:
@@ -93,6 +93,35 @@ class AgentHook:
|
||||
async def before_execute_tools(self, context: AgentHookContext) -> None:
|
||||
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:
|
||||
pass
|
||||
|
||||
@@ -166,6 +195,49 @@ class CompositeHook(AgentHook):
|
||||
async def before_execute_tools(self, context: AgentHookContext) -> None:
|
||||
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:
|
||||
await self._for_each_hook_safe("emit_reasoning", reasoning_content)
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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
@@ -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.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
||||
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 (
|
||||
IncrementalThinkExtractor,
|
||||
build_assistant_message,
|
||||
@@ -40,10 +30,6 @@ from nanobot.utils.helpers import (
|
||||
strip_reasoning_tags,
|
||||
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.runtime import (
|
||||
EMPTY_FINAL_RESPONSE_MESSAGE,
|
||||
@@ -68,10 +54,6 @@ _MAX_EMPTY_RETRIES = 2
|
||||
_MAX_LENGTH_RECOVERIES = 3
|
||||
_MAX_INJECTIONS_PER_TURN = 3
|
||||
_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)
|
||||
class AgentRunSpec:
|
||||
@@ -461,6 +443,8 @@ class AgentRunner:
|
||||
response.tool_calls,
|
||||
external_lookup_counts,
|
||||
workspace_violation_counts,
|
||||
hook,
|
||||
context,
|
||||
)
|
||||
tool_events.extend(new_events)
|
||||
tools_used.extend(
|
||||
@@ -766,24 +750,6 @@ class AgentRunner:
|
||||
)
|
||||
|
||||
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:
|
||||
thinking_buf = ""
|
||||
@@ -812,7 +778,6 @@ class AgentRunner:
|
||||
**kwargs,
|
||||
on_content_delta=_stream,
|
||||
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,
|
||||
)
|
||||
elif wants_progress_streaming:
|
||||
@@ -843,7 +808,6 @@ class AgentRunner:
|
||||
coro = self.provider.chat_stream_with_retry(
|
||||
**kwargs,
|
||||
on_content_delta=_stream_progress,
|
||||
on_tool_call_delta=_tool_call_delta if live_file_edits is not None else None,
|
||||
)
|
||||
else:
|
||||
coro = self.provider.chat_with_retry(**kwargs)
|
||||
@@ -858,14 +822,6 @@ class AgentRunner:
|
||||
await coro if outer_timeout_s is None
|
||||
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:
|
||||
if outer_timeout_s is None:
|
||||
return LLMResponse(
|
||||
@@ -1131,14 +1087,23 @@ class AgentRunner:
|
||||
tool_calls: list[ToolCallRequest],
|
||||
external_lookup_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]:
|
||||
hook = hook or AgentHook()
|
||||
context = context or AgentHookContext(iteration=0, messages=[])
|
||||
batches = self._partition_tool_batches(spec, tool_calls)
|
||||
tool_results: list[tuple[Any, dict[str, str], BaseException | None]] = []
|
||||
for batch in batches:
|
||||
if spec.concurrent_tools and len(batch) > 1:
|
||||
batch_results = await asyncio.gather(*(
|
||||
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
|
||||
))
|
||||
@@ -1147,7 +1112,12 @@ class AgentRunner:
|
||||
batch_results = []
|
||||
for tool_call in batch:
|
||||
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)
|
||||
batch_results.append(result)
|
||||
@@ -1168,7 +1138,11 @@ class AgentRunner:
|
||||
tool_call: ToolCallRequest,
|
||||
external_lookup_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]:
|
||||
hook = hook or AgentHook()
|
||||
context = context or AgentHookContext(iteration=0, messages=[])
|
||||
hint = "\n\n[Analyze the error above and try a different approach.]"
|
||||
lookup_error = repeated_external_lookup_error(
|
||||
tool_call.name,
|
||||
@@ -1209,30 +1183,7 @@ class AgentRunner:
|
||||
return prep_error + hint, event, (
|
||||
RuntimeError(prep_error) if spec.fail_on_tool_error else None
|
||||
)
|
||||
emit_file_edit_events = (
|
||||
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],
|
||||
)
|
||||
await hook.before_execute_tool(context, tool_call, tool, params)
|
||||
try:
|
||||
if tool is not None:
|
||||
result = await tool.execute(**params)
|
||||
@@ -1241,14 +1192,7 @@ class AgentRunner:
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except BaseException as exc:
|
||||
if file_edit_trackers and progress_callback is not None:
|
||||
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
|
||||
],
|
||||
)
|
||||
await hook.on_execute_tool_error(context, tool_call, tool, params, exc)
|
||||
event = {
|
||||
"name": tool_call.name,
|
||||
"status": "error",
|
||||
@@ -1270,14 +1214,7 @@ class AgentRunner:
|
||||
return payload, event, None
|
||||
|
||||
if is_tool_error_result(tool_call.name, result):
|
||||
if file_edit_trackers and progress_callback is not None:
|
||||
await invoke_file_edit_progress(
|
||||
progress_callback,
|
||||
[
|
||||
build_file_edit_error_event(file_edit_tracker, result)
|
||||
for file_edit_tracker in file_edit_trackers
|
||||
],
|
||||
)
|
||||
await hook.on_execute_tool_error(context, tool_call, tool, params, result)
|
||||
event = {
|
||||
"name": tool_call.name,
|
||||
"status": "error",
|
||||
@@ -1296,14 +1233,7 @@ class AgentRunner:
|
||||
return result + hint, event, RuntimeError(result)
|
||||
return result + hint, event, None
|
||||
|
||||
if file_edit_trackers and progress_callback is not None:
|
||||
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],
|
||||
)
|
||||
await hook.after_execute_tool(context, tool_call, tool, params, result)
|
||||
|
||||
detail = "" if result is None else str(result)
|
||||
detail = detail.replace("\n", " ").strip()
|
||||
|
||||
@@ -61,6 +61,7 @@ from rich.text import Text # noqa: E402
|
||||
|
||||
from nanobot import __logo__, __version__ # 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.bus.outbound_events import ( # noqa: E402
|
||||
ProgressEvent,
|
||||
@@ -1177,6 +1178,7 @@ def serve(
|
||||
runtime_config, bus,
|
||||
session_manager=session_manager,
|
||||
image_generation_provider_configs=image_gen_provider_configs(runtime_config),
|
||||
hook_factories=[create_file_edit_activity_hook],
|
||||
)
|
||||
except ValueError as exc:
|
||||
console.print(f"[red]Error: {exc}[/red]")
|
||||
@@ -1429,6 +1431,7 @@ def _run_gateway(
|
||||
provider_signature=provider_snapshot.signature,
|
||||
hooks=[TokenUsageHook(timezone_name=config.agents.defaults.timezone)],
|
||||
local_trigger_store=trigger_store,
|
||||
hook_factories=[create_file_edit_activity_hook],
|
||||
)
|
||||
WebuiTurnCoordinator(
|
||||
bus=bus,
|
||||
@@ -1914,6 +1917,7 @@ def agent(
|
||||
config, bus,
|
||||
cron_service=cron,
|
||||
image_generation_provider_configs=image_gen_provider_configs(config),
|
||||
hook_factories=[create_file_edit_activity_hook],
|
||||
)
|
||||
except ValueError as exc:
|
||||
console.print(f"[red]Error: {exc}[/red]")
|
||||
|
||||
@@ -8,6 +8,7 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
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.config.schema import Config
|
||||
from nanobot.providers.image_generation import image_gen_provider_configs
|
||||
@@ -120,6 +121,7 @@ class Nanobot:
|
||||
loop = AgentLoop.from_config(
|
||||
config,
|
||||
image_generation_provider_configs=image_gen_provider_configs(config),
|
||||
hook_factories=[create_file_edit_activity_hook],
|
||||
)
|
||||
return cls(loop, config=config)
|
||||
|
||||
|
||||
+233
-688
File diff suppressed because it is too large
Load Diff
@@ -1525,16 +1525,50 @@ def replay_transcript_to_ui_messages(
|
||||
)
|
||||
):
|
||||
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
|
||||
|
||||
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(
|
||||
edits: list[dict[str, Any]],
|
||||
idx: int,
|
||||
@@ -1549,12 +1583,12 @@ def replay_transcript_to_ui_messages(
|
||||
segment = _new_activity_segment(activate=False)
|
||||
active_file_edit_segment_id = 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)
|
||||
if target_index is not None:
|
||||
last = messages[target_index]
|
||||
segment = str(last.get("activitySegmentId") or segment or _new_activity_segment(activate=False))
|
||||
active_file_edit_segment_id = segment
|
||||
last = _strip_covered_file_edit_tool_hints(last, edits)
|
||||
else:
|
||||
if not segment:
|
||||
segment = _new_activity_segment(activate=False)
|
||||
|
||||
@@ -73,6 +73,9 @@ async def test_composite_fans_out_all_async_methods():
|
||||
async def emit_reasoning(self, reasoning_content: str | None) -> None:
|
||||
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:
|
||||
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:
|
||||
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:
|
||||
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_iteration(ctx)
|
||||
await hook.emit_reasoning("thinking...")
|
||||
await hook.emit_reasoning_end()
|
||||
await hook.on_stream(ctx, "hi")
|
||||
await hook.on_stream_end(ctx, resuming=True)
|
||||
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_run(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_iteration", "before_iteration",
|
||||
"emit_reasoning:thinking...", "emit_reasoning:thinking...",
|
||||
"emit_reasoning_end", "emit_reasoning_end",
|
||||
"on_stream:hi", "on_stream:hi",
|
||||
"on_stream_end:True", "on_stream_end:True",
|
||||
"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_run", "after_run",
|
||||
"on_error", "on_error",
|
||||
@@ -172,10 +192,20 @@ async def test_composite_error_isolation_all_async():
|
||||
raise RuntimeError("err")
|
||||
async def emit_reasoning(self, reasoning_content):
|
||||
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):
|
||||
raise RuntimeError("err")
|
||||
async def before_execute_tools(self, context):
|
||||
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):
|
||||
raise RuntimeError("err")
|
||||
async def after_run(self, context):
|
||||
@@ -190,10 +220,20 @@ async def test_composite_error_isolation_all_async():
|
||||
calls.append("before_run")
|
||||
async def emit_reasoning(self, reasoning_content):
|
||||
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):
|
||||
calls.append("on_stream_end")
|
||||
async def before_execute_tools(self, context):
|
||||
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):
|
||||
calls.append("after_iteration")
|
||||
async def after_run(self, context):
|
||||
@@ -208,8 +248,13 @@ async def test_composite_error_isolation_all_async():
|
||||
run_ctx = _run_ctx()
|
||||
await hook.before_run(run_ctx)
|
||||
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.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_run(run_ctx)
|
||||
await hook.on_error(run_ctx)
|
||||
@@ -217,8 +262,13 @@ async def test_composite_error_isolation_all_async():
|
||||
assert calls == [
|
||||
"before_run",
|
||||
"emit_reasoning",
|
||||
"emit_reasoning_end",
|
||||
"on_stream",
|
||||
"on_stream_end",
|
||||
"before_execute_tools",
|
||||
"before_execute_tool",
|
||||
"after_execute_tool",
|
||||
"on_execute_tool_error",
|
||||
"after_iteration",
|
||||
"after_run",
|
||||
"on_error",
|
||||
@@ -313,6 +363,9 @@ async def test_composite_empty_hooks_no_ops():
|
||||
await hook.on_stream(ctx, "delta")
|
||||
await hook.on_stream_end(ctx, resuming=False)
|
||||
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_run(run_ctx)
|
||||
await hook.on_error(run_ctx)
|
||||
|
||||
@@ -6,8 +6,9 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
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.tools.filesystem import WriteFileTool
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.outbound_events import (
|
||||
GoalStatusEvent,
|
||||
@@ -31,7 +32,13 @@ def _make_loop(tmp_path: Path) -> AgentLoop:
|
||||
bus = MessageBus()
|
||||
provider = MagicMock()
|
||||
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:
|
||||
@@ -122,15 +129,10 @@ class TestToolEventProgress:
|
||||
])
|
||||
loop.provider.chat_with_retry = AsyncMock(side_effect=lambda *a, **kw: next(calls))
|
||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||
tool = WriteFileTool(workspace=tmp_path)
|
||||
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] = []
|
||||
|
||||
async def on_progress(
|
||||
@@ -154,14 +156,15 @@ class TestToolEventProgress:
|
||||
"path": "foo.txt",
|
||||
"absolute_path": (tmp_path / "foo.txt").resolve().as_posix(),
|
||||
"phase": "start",
|
||||
"added": 2,
|
||||
"deleted": 1,
|
||||
"added": 0,
|
||||
"deleted": 0,
|
||||
"approximate": True,
|
||||
"status": "editing",
|
||||
}
|
||||
assert file_events[1]["status"] == "done"
|
||||
assert file_events[1]["approximate"] is False
|
||||
assert (file_events[1]["added"], file_events[1]["deleted"]) == (2, 1)
|
||||
assert file_events[1]["diff"]["format"] == "unified"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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)
|
||||
target = tmp_path / "foo.txt"
|
||||
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(
|
||||
id="call-write",
|
||||
name="write_file",
|
||||
@@ -184,17 +197,9 @@ class TestToolEventProgress:
|
||||
loop.provider.chat_with_retry = AsyncMock(side_effect=lambda *a, **kw: next(calls))
|
||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||
loop.tools.prepare_call = MagicMock(
|
||||
return_value=(None, {"path": "foo.txt", "content": "new\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(
|
||||
content: str,
|
||||
*,
|
||||
@@ -203,11 +208,16 @@ class TestToolEventProgress:
|
||||
) -> None:
|
||||
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)
|
||||
|
||||
assert final_content == "Done"
|
||||
assert target.read_text(encoding="utf-8") == "new\n"
|
||||
prepare_tracker.assert_not_called()
|
||||
prepare_file_edit_trackers.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
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
|
||||
|
||||
@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."""
|
||||
bus = MessageBus()
|
||||
provider = MagicMock()
|
||||
provider.supports_progress_deltas = True
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
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
|
||||
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(
|
||||
content=None,
|
||||
tool_calls=[
|
||||
@@ -383,25 +380,26 @@ class TestToolEventProgress:
|
||||
)
|
||||
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_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=[
|
||||
{"type": "function", "function": {"name": "write_file"}},
|
||||
])
|
||||
loop.tools.prepare_call = MagicMock(
|
||||
return_value=(
|
||||
None,
|
||||
tool,
|
||||
{"path": "goal.txt", "content": "one\ntwo\nthree\n"},
|
||||
None,
|
||||
),
|
||||
)
|
||||
loop.tools.execute = AsyncMock(side_effect=execute)
|
||||
loop.consolidator.maybe_consolidate_by_tokens = AsyncMock(return_value=False) # type: ignore[method-assign]
|
||||
|
||||
await loop._dispatch(InboundMessage(
|
||||
@@ -425,13 +423,14 @@ class TestToolEventProgress:
|
||||
assert any(
|
||||
event["status"] == "editing"
|
||||
and event["approximate"]
|
||||
and event["added"] == 3
|
||||
and event["added"] == 0
|
||||
for event in edit_events
|
||||
)
|
||||
assert any(
|
||||
event["status"] == "done"
|
||||
and not event["approximate"]
|
||||
and event["added"] == 3
|
||||
and event.get("diff", {}).get("format") == "unified"
|
||||
for event in edit_events
|
||||
)
|
||||
provider.chat_with_retry.assert_not_awaited()
|
||||
|
||||
@@ -47,6 +47,12 @@ async def test_runner_calls_hooks_in_order():
|
||||
[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:
|
||||
events.append((
|
||||
"after_iteration",
|
||||
@@ -75,6 +81,8 @@ async def test_runner_calls_hooks_in_order():
|
||||
assert events == [
|
||||
("before_iteration", 0),
|
||||
("before_execute_tools", 0, ["list_dir"]),
|
||||
("before_execute_tool", 0, "list_dir", {"path": "."}),
|
||||
("after_execute_tool", 0, "list_dir", "tool result"),
|
||||
(
|
||||
"after_iteration",
|
||||
0,
|
||||
|
||||
@@ -4,7 +4,9 @@ from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from nanobot.agent.hooks import FileEditActivityHook
|
||||
from nanobot.agent.runner import AgentRunner, AgentRunSpec
|
||||
from nanobot.agent.tools.filesystem import EditFileTool, WriteFileTool
|
||||
from nanobot.config.schema import AgentDefaults
|
||||
from nanobot.providers.base import LLMResponse, ToolCallRequest
|
||||
|
||||
@@ -80,42 +82,30 @@ async def test_runner_streams_provider_progress_deltas_by_default():
|
||||
|
||||
|
||||
@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.supports_progress_deltas = True
|
||||
call_count = 0
|
||||
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):
|
||||
if file_edit_events:
|
||||
progress_events.extend(file_edit_events)
|
||||
|
||||
tool = WriteFileTool(workspace=tmp_path)
|
||||
|
||||
class Tools:
|
||||
def get_definitions(self):
|
||||
return [{"type": "function", "function": {"name": "write_file"}}]
|
||||
|
||||
def get(self, name):
|
||||
return None
|
||||
def prepare_call(self, name, params):
|
||||
return tool, params, None
|
||||
|
||||
async def execute(self, name, params):
|
||||
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):
|
||||
async def chat_stream_with_retry(**kwargs):
|
||||
nonlocal call_count
|
||||
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(
|
||||
content=None,
|
||||
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_with_retry = AsyncMock()
|
||||
tools = Tools()
|
||||
|
||||
runner = AgentRunner(provider)
|
||||
result = await runner.run(AgentRunSpec(
|
||||
initial_messages=[{"role": "user", "content": "write a large file"}],
|
||||
tools=Tools(),
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=2,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
progress_callback=progress_cb,
|
||||
workspace=tmp_path,
|
||||
hook=FileEditActivityHook(on_progress=progress_cb, workspace=tmp_path),
|
||||
))
|
||||
|
||||
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(
|
||||
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
|
||||
)
|
||||
provider.chat_with_retry.assert_not_awaited()
|
||||
|
||||
|
||||
@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.supports_progress_deltas = True
|
||||
call_count = 0
|
||||
@@ -165,43 +163,19 @@ async def test_runner_streams_live_edit_file_activity_from_tool_argument_deltas(
|
||||
if file_edit_events:
|
||||
progress_events.extend(file_edit_events)
|
||||
|
||||
tool = EditFileTool(workspace=tmp_path)
|
||||
|
||||
class Tools:
|
||||
def get_definitions(self):
|
||||
return [{"type": "function", "function": {"name": "edit_file"}}]
|
||||
|
||||
def get(self, name):
|
||||
return None
|
||||
def prepare_call(self, name, params):
|
||||
return tool, params, None
|
||||
|
||||
async def execute(self, name, params):
|
||||
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):
|
||||
async def chat_stream_with_retry(**kwargs):
|
||||
nonlocal call_count
|
||||
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(
|
||||
content=None,
|
||||
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_with_retry = AsyncMock()
|
||||
tools = Tools()
|
||||
|
||||
runner = AgentRunner(provider)
|
||||
result = await runner.run(AgentRunSpec(
|
||||
initial_messages=[{"role": "user", "content": "edit a file"}],
|
||||
tools=Tools(),
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=2,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
progress_callback=progress_cb,
|
||||
workspace=tmp_path,
|
||||
hook=FileEditActivityHook(on_progress=progress_cb, workspace=tmp_path),
|
||||
))
|
||||
|
||||
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(
|
||||
event["tool"] == "edit_file"
|
||||
and not event["approximate"]
|
||||
and event["phase"] == "end"
|
||||
and event["added"] == 2
|
||||
and event["deleted"] == 1
|
||||
and event["diff"]["format"] == "unified"
|
||||
for event in progress_events
|
||||
)
|
||||
provider.chat_with_retry.assert_not_awaited()
|
||||
|
||||
|
||||
@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.supports_progress_deltas = True
|
||||
call_count = 0
|
||||
progress_events: list[dict] = []
|
||||
|
||||
async def progress_cb(content, *, file_edit_events=None, **kwargs):
|
||||
if file_edit_events:
|
||||
progress_events.extend(file_edit_events)
|
||||
|
||||
async def chat_stream_with_retry(*, on_tool_call_delta=None, **kwargs):
|
||||
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":"aborted.txt","content":"partial\\n',
|
||||
})
|
||||
return LLMResponse(content="stopped", tool_calls=[], finish_reason="stop", usage={})
|
||||
tool = WriteFileTool(workspace=tmp_path)
|
||||
|
||||
class Tools:
|
||||
def get_definitions(self):
|
||||
return [{"type": "function", "function": {"name": "write_file"}}]
|
||||
|
||||
def prepare_call(self, name, params):
|
||||
return tool, params, None
|
||||
|
||||
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_with_retry = AsyncMock()
|
||||
tools = MagicMock()
|
||||
tools.get_definitions.return_value = [{"type": "function", "function": {"name": "write_file"}}]
|
||||
tools.get.return_value = None
|
||||
tools = Tools()
|
||||
|
||||
runner = AgentRunner(provider)
|
||||
result = await runner.run(AgentRunSpec(
|
||||
initial_messages=[{"role": "user", "content": "write a large file"}],
|
||||
initial_messages=[{"role": "user", "content": "write a file"}],
|
||||
tools=tools,
|
||||
model="test-model",
|
||||
max_iterations=1,
|
||||
max_iterations=2,
|
||||
max_tool_result_chars=_MAX_TOOL_RESULT_CHARS,
|
||||
progress_callback=progress_cb,
|
||||
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]["phase"] == "error"
|
||||
assert progress_events[-1]["status"] == "error"
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
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 (
|
||||
StreamingFileEditTracker,
|
||||
build_file_edit_end_event,
|
||||
build_file_edit_start_event,
|
||||
build_unified_diff_payload,
|
||||
line_diff_stats,
|
||||
prepare_file_edit_tracker,
|
||||
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:
|
||||
added, deleted = line_diff_stats("a\nb\nc\n", "a\nB\nc\nd\n")
|
||||
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)
|
||||
|
||||
|
||||
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.write_text("old\nkeep\n", encoding="utf-8")
|
||||
params = {"path": "notes.txt", "content": "new\nkeep\nextra\n"}
|
||||
tracker = prepare_file_edit_tracker(
|
||||
call_id="call-write",
|
||||
tool_name="write_file",
|
||||
tool=None,
|
||||
tool=_write_tool(tmp_path),
|
||||
workspace=tmp_path,
|
||||
params=params,
|
||||
)
|
||||
|
||||
assert tracker is not None
|
||||
start = build_file_edit_start_event(tracker, params)
|
||||
start = build_file_edit_start_event(tracker)
|
||||
assert start == {
|
||||
"version": 1,
|
||||
"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",
|
||||
"absolute_path": (tmp_path / "notes.txt").resolve().as_posix(),
|
||||
"phase": "start",
|
||||
"added": 2,
|
||||
"deleted": 1,
|
||||
"added": 0,
|
||||
"deleted": 0,
|
||||
"approximate": True,
|
||||
"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["approximate"] is False
|
||||
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:
|
||||
@@ -69,7 +106,7 @@ def test_binary_file_is_reported_but_not_counted(tmp_path: Path) -> None:
|
||||
tracker = prepare_file_edit_tracker(
|
||||
call_id="call-bin",
|
||||
tool_name="edit_file",
|
||||
tool=None,
|
||||
tool=_edit_tool(tmp_path),
|
||||
workspace=tmp_path,
|
||||
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)
|
||||
assert event["binary"] is True
|
||||
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:
|
||||
@@ -95,7 +152,7 @@ def test_apply_patch_prepares_trackers_for_each_touched_file(tmp_path: Path) ->
|
||||
trackers = prepare_file_edit_trackers(
|
||||
call_id="call-patch",
|
||||
tool_name="apply_patch",
|
||||
tool=None,
|
||||
tool=_patch_tool(tmp_path),
|
||||
workspace=tmp_path,
|
||||
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")
|
||||
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}
|
||||
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/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:
|
||||
@@ -120,7 +199,7 @@ def test_apply_patch_dry_run_does_not_prepare_file_edit_trackers(tmp_path: Path)
|
||||
trackers = prepare_file_edit_trackers(
|
||||
call_id="call-patch",
|
||||
tool_name="apply_patch",
|
||||
tool=None,
|
||||
tool=_patch_tool(tmp_path),
|
||||
workspace=tmp_path,
|
||||
params={
|
||||
"dry_run": True,
|
||||
@@ -133,429 +212,24 @@ def test_apply_patch_dry_run_does_not_prepare_file_edit_trackers(tmp_path: Path)
|
||||
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"
|
||||
params = {"path": "large.txt", "content": "x" * (2 * 1024 * 1024 + 1)}
|
||||
params = {"path": "large.txt", "content": "x"}
|
||||
tracker = prepare_file_edit_tracker(
|
||||
call_id="call-large",
|
||||
tool_name="write_file",
|
||||
tool=None,
|
||||
tool=_write_tool(tmp_path),
|
||||
workspace=tmp_path,
|
||||
params=params,
|
||||
)
|
||||
|
||||
assert tracker is not None
|
||||
target.write_text(params["content"], encoding="utf-8")
|
||||
event = build_file_edit_end_event(tracker, params)
|
||||
assert event.get("binary") is not True
|
||||
assert event["added"] == 1
|
||||
target.write_text("x" * (2 * 1024 * 1024 + 1), encoding="utf-8")
|
||||
event = build_file_edit_end_event(tracker)
|
||||
assert event["binary"] is True
|
||||
assert event["added"] == 0
|
||||
assert event["deleted"] == 0
|
||||
|
||||
|
||||
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)
|
||||
assert "diff" not in event
|
||||
|
||||
|
||||
def test_untracked_tools_do_not_prepare_file_edit_tracker(tmp_path: Path) -> None:
|
||||
|
||||
@@ -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:
|
||||
msgs = replay_transcript_to_ui_messages([
|
||||
{
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
"@radix-ui/react-tooltip": "^1.1.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"diff": "^9.0.0",
|
||||
"i18next": "^26.0.6",
|
||||
"lucide-react": "^0.469.0",
|
||||
"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=="],
|
||||
|
||||
"diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
|
||||
|
||||
"didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="],
|
||||
|
||||
"dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="],
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"@radix-ui/react-tooltip": "^1.1.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"diff": "^9.0.0",
|
||||
"i18next": "^26.0.6",
|
||||
"lucide-react": "^0.469.0",
|
||||
"react": "^18.3.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } 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 { CodeBlock } from "@/components/CodeBlock";
|
||||
@@ -24,11 +24,6 @@ type PreviewState =
|
||||
| { status: "error"; message: string }
|
||||
| { 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({
|
||||
sessionKey,
|
||||
path,
|
||||
@@ -41,26 +36,12 @@ export function FilePreviewPanel({
|
||||
const { t } = useTranslation();
|
||||
const [state, setState] = useState<PreviewState>({ status: "loading" });
|
||||
const [entered, setEntered] = useState(false);
|
||||
const [supportsHoverClose, setSupportsHoverClose] = useState(supportsHoverCloseControl);
|
||||
|
||||
useEffect(() => {
|
||||
const frame = window.requestAnimationFrame(() => setEntered(true));
|
||||
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(() => {
|
||||
let cancelled = false;
|
||||
setState({ status: "loading" });
|
||||
@@ -89,15 +70,28 @@ export function FilePreviewPanel({
|
||||
const normalizedPreviewPath = previewPath.replace(/\\/g, "/");
|
||||
const hasRootPrefix = normalizedPreviewPath.startsWith("/");
|
||||
const { name } = splitFilePath(displayPath);
|
||||
const breadcrumbs = useMemo(
|
||||
const fileName = name || displayPath;
|
||||
const pathParts = useMemo(
|
||||
() => normalizedPreviewPath.split("/").filter(Boolean),
|
||||
[normalizedPreviewPath],
|
||||
);
|
||||
const compactBreadcrumbs = useMemo(
|
||||
() => (breadcrumbs.length > 2 ? breadcrumbs.slice(-2) : breadcrumbs),
|
||||
[breadcrumbs],
|
||||
const directoryParts = useMemo(
|
||||
() => (pathParts.length > 1 ? pathParts.slice(0, -1) : []),
|
||||
[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 (
|
||||
<aside
|
||||
@@ -144,144 +138,115 @@ export function FilePreviewPanel({
|
||||
</button>
|
||||
) : null}
|
||||
<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">
|
||||
{supportsHoverClose ? (
|
||||
<div
|
||||
className={cn(
|
||||
"group inline-flex max-w-full min-w-0 items-center gap-2 rounded-[12px]",
|
||||
"bg-muted/70 px-2.5 py-1.5 text-sm font-medium",
|
||||
)}
|
||||
title={name || displayPath}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
"relative inline-flex h-5 w-5 shrink-0 items-center justify-center overflow-hidden rounded-full",
|
||||
"text-muted-foreground/75 transition-[background-color,color,opacity] duration-150 ease-out",
|
||||
"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",
|
||||
)}
|
||||
<div
|
||||
className="flex h-11 shrink-0 items-center gap-2 border-b border-border/60 px-3"
|
||||
title={previewPath}
|
||||
>
|
||||
<nav
|
||||
aria-label={t("filePreview.breadcrumb", { defaultValue: "File path" })}
|
||||
className="flex min-w-0 flex-1 items-center overflow-hidden text-sm leading-5"
|
||||
title={breadcrumbTitle}
|
||||
data-testid="file-preview-breadcrumb"
|
||||
>
|
||||
{hasCompactPrefix ? (
|
||||
<>
|
||||
<span className="shrink-0 text-muted-foreground/55">...</span>
|
||||
<ChevronRight
|
||||
className="mx-1 h-3.5 w-3.5 shrink-0 text-muted-foreground/35"
|
||||
aria-hidden
|
||||
/>
|
||||
<X
|
||||
className={cn(
|
||||
"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",
|
||||
"group-focus-within:scale-100 group-focus-within:opacity-100",
|
||||
)}
|
||||
</>
|
||||
) : hasRootPrefix ? (
|
||||
<>
|
||||
<span className="shrink-0 text-muted-foreground/55">/</span>
|
||||
<ChevronRight
|
||||
className="mx-1 h-3.5 w-3.5 shrink-0 text-muted-foreground/35"
|
||||
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>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className={cn(
|
||||
"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",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
|
||||
)}
|
||||
aria-label={t("filePreview.close", { defaultValue: "Close file preview" })}
|
||||
>
|
||||
<X className="h-5 w-5" aria-hidden />
|
||||
</button>
|
||||
<span className="min-w-0 truncate text-sm font-medium">
|
||||
{name || displayPath}
|
||||
</span>
|
||||
</>
|
||||
<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 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>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -111,6 +111,14 @@ import {
|
||||
} from "@/lib/api";
|
||||
import { notifyCliAppsChanged } from "@/lib/cli-app-events";
|
||||
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 { notifyMcpPresetsChanged } from "@/lib/mcp-preset-events";
|
||||
import { fmtDateTime, relativeTime } from "@/lib/format";
|
||||
@@ -155,8 +163,6 @@ export type SettingsSectionKey =
|
||||
| "runtime"
|
||||
| "advanced";
|
||||
|
||||
type LocalDensity = "comfortable" | "compact";
|
||||
type LocalActivityMode = "auto" | "expanded";
|
||||
type AppsKindFilter = "all" | "nanobot" | "cli" | "mcp";
|
||||
type AutomationFilter = "all" | "active" | "paused" | "failed" | "system";
|
||||
type AutomationSort = "next" | "last" | "updated" | "name";
|
||||
@@ -166,13 +172,6 @@ type AppsCatalogItem =
|
||||
| { id: string; kind: "cli"; app: CliAppInfo }
|
||||
| { id: string; kind: "mcp"; preset: McpPresetInfo };
|
||||
|
||||
interface LocalPreferences {
|
||||
density: LocalDensity;
|
||||
activityMode: LocalActivityMode;
|
||||
codeWrap: boolean;
|
||||
brandLogos: boolean;
|
||||
}
|
||||
|
||||
interface AgentSettingsDraft {
|
||||
model: string;
|
||||
provider: string;
|
||||
@@ -259,14 +258,6 @@ interface CustomMcpForm {
|
||||
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 }> = [
|
||||
{ value: "auto", label: "Auto" },
|
||||
{ value: "chat_completions", label: "Chat Completions" },
|
||||
@@ -318,22 +309,6 @@ interface SettingsViewProps {
|
||||
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 {
|
||||
return payload.agent.model_preset || "default";
|
||||
}
|
||||
@@ -2337,6 +2312,25 @@ function AppearanceSettings({
|
||||
}
|
||||
/>
|
||||
</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
|
||||
title={tx("settings.rows.codeWrap", "Code wrapping")}
|
||||
description={tx("settings.help.codeWrap", "Keep long code lines readable on smaller screens.")}
|
||||
|
||||
@@ -29,6 +29,9 @@ import {
|
||||
isReasoningOnlyAssistant,
|
||||
type ActivityEvidence,
|
||||
} 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 { formatToolCallTrace } from "@/lib/tool-traces";
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -190,6 +193,7 @@ export function AgentActivityCluster({
|
||||
onOpenFilePreview,
|
||||
}: AgentActivityClusterProps) {
|
||||
const { t } = useTranslation();
|
||||
const fileEditDisplayMode = useFileEditDisplayMode();
|
||||
const fileEdits = useMemo(
|
||||
() => summarizeFileEdits(collectFileEdits(messages), isTurnStreaming),
|
||||
[messages, isTurnStreaming],
|
||||
@@ -282,7 +286,7 @@ export function AgentActivityCluster({
|
||||
})
|
||||
: t(fileActivityManySummaryKey(hasLiveEditingFiles, hasFailedFiles, hasDeletedFiles), {
|
||||
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}
|
||||
deleted={deleted}
|
||||
hasDiffStats={hasDiffStats}
|
||||
fileEditDisplayMode={fileEditDisplayMode}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
/>
|
||||
);
|
||||
@@ -532,6 +537,7 @@ export function AgentActivityCluster({
|
||||
{fileEdits.length ? (
|
||||
<FileEditGroup
|
||||
edits={fileEdits}
|
||||
displayMode={fileEditDisplayMode}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
/>
|
||||
) : null}
|
||||
@@ -561,6 +567,7 @@ function FileEditFlatActivity({
|
||||
added,
|
||||
deleted,
|
||||
hasDiffStats,
|
||||
fileEditDisplayMode,
|
||||
onOpenFilePreview,
|
||||
}: {
|
||||
edits: FileEditSummary[];
|
||||
@@ -575,9 +582,23 @@ function FileEditFlatActivity({
|
||||
added: number;
|
||||
deleted: number;
|
||||
hasDiffStats: boolean;
|
||||
fileEditDisplayMode: FileEditDisplayMode;
|
||||
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 (
|
||||
<div className={cn("w-full", hasBodyBelow && "mb-2")} aria-label={summary}>
|
||||
<div
|
||||
@@ -611,7 +632,12 @@ function FileEditFlatActivity({
|
||||
</div>
|
||||
{showRows ? (
|
||||
<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>
|
||||
) : null}
|
||||
</div>
|
||||
@@ -1579,122 +1605,32 @@ function latestFileEditEvents(edits: UIFileEdit[]): UIFileEdit[] {
|
||||
}
|
||||
|
||||
function summarizeFileEdits(edits: UIFileEdit[], active: boolean): FileEditSummary[] {
|
||||
interface MutableSummary {
|
||||
key: string;
|
||||
path: string;
|
||||
absolute_path?: string;
|
||||
added: number;
|
||||
deleted: number;
|
||||
approximate: boolean;
|
||||
binary: boolean;
|
||||
pending: boolean;
|
||||
hasSuccessfulChange: boolean;
|
||||
hasActiveEditing: boolean;
|
||||
hasFailed: boolean;
|
||||
operation?: UIFileEdit["operation"];
|
||||
error?: string;
|
||||
}
|
||||
return latestFileEditEvents(edits).flatMap((edit) => {
|
||||
const editing = active && edit.status === "editing";
|
||||
const failed = edit.status === "error";
|
||||
if (!edit.path && edit.pending && !editing) return [];
|
||||
if (!edit.path && !editing && !failed) return [];
|
||||
|
||||
const order: string[] = [];
|
||||
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
|
||||
const status: UIFileEdit["status"] = editing
|
||||
? "editing"
|
||||
: summary.hasSuccessfulChange
|
||||
? "done"
|
||||
: summary.hasFailed
|
||||
? "error"
|
||||
: "done";
|
||||
: failed
|
||||
? "error"
|
||||
: "done";
|
||||
const binary = !!edit.binary;
|
||||
const diff = hasRenderableFileDiff(edit.diff) ? edit.diff : undefined;
|
||||
return [{
|
||||
key: summary.key,
|
||||
path: summary.path,
|
||||
absolute_path: summary.absolute_path,
|
||||
added: summary.added,
|
||||
deleted: summary.deleted,
|
||||
approximate: summary.approximate,
|
||||
binary: summary.binary,
|
||||
key: fileEditCallKey(edit),
|
||||
path: edit.path || "",
|
||||
absolute_path: edit.absolute_path,
|
||||
added: binary ? 0 : edit.added,
|
||||
deleted: binary ? 0 : edit.deleted,
|
||||
approximate: active && !!edit.approximate,
|
||||
binary,
|
||||
status,
|
||||
operation: summary.operation,
|
||||
pending: summary.pending && !summary.path,
|
||||
error: summary.error,
|
||||
operation: edit.operation,
|
||||
pending: !!edit.pending && !edit.path,
|
||||
error: edit.error,
|
||||
diff,
|
||||
}];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
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>
|
||||
{sign}
|
||||
<AnimatedNumber value={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>
|
||||
))}
|
||||
{safeValue}
|
||||
</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 { 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 { ActivityStep } from "./ActivityStep";
|
||||
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 {
|
||||
key: string;
|
||||
path: string;
|
||||
@@ -20,40 +54,97 @@ export interface FileEditSummary {
|
||||
operation?: UIFileEdit["operation"];
|
||||
pending: boolean;
|
||||
error?: string;
|
||||
diff?: UIFileDiff;
|
||||
}
|
||||
|
||||
export function FileEditGroup({
|
||||
edits,
|
||||
displayMode,
|
||||
onOpenFilePreview,
|
||||
density = "default",
|
||||
}: {
|
||||
edits: FileEditSummary[];
|
||||
displayMode: FileEditDisplayMode;
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
density?: "default" | "diff-only";
|
||||
}) {
|
||||
if (edits.length === 0) return null;
|
||||
return (
|
||||
<ul className="space-y-1">
|
||||
{edits.map((edit) => (
|
||||
<FileEditRow
|
||||
key={edit.key}
|
||||
edit={edit}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
/>
|
||||
))}
|
||||
{edits.map((edit) => {
|
||||
if (density === "diff-only" && canRenderDiff(edit, displayMode)) {
|
||||
return (
|
||||
<FileEditDiffOnly
|
||||
key={edit.key}
|
||||
edit={edit}
|
||||
displayMode={displayMode}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<FileEditRow
|
||||
key={edit.key}
|
||||
edit={edit}
|
||||
displayMode={displayMode}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</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({
|
||||
edit,
|
||||
displayMode,
|
||||
onOpenFilePreview,
|
||||
}: {
|
||||
edit: FileEditSummary;
|
||||
displayMode: FileEditDisplayMode;
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const editing = edit.status === "editing";
|
||||
const failed = edit.status === "error";
|
||||
const hasCountedDiff = !failed && !edit.binary && hasVisibleDiffStats(edit);
|
||||
const showDiff = canRenderDiff(edit, displayMode);
|
||||
const rawFailureDetail = failed ? cleanFileEditError(edit.error) : "";
|
||||
const failureDetail = failed
|
||||
? formatFileEditError(edit.error)
|
||||
@@ -84,7 +175,7 @@ function FileEditRow({
|
||||
active={editing}
|
||||
tone={failed ? "error" : editing ? "active" : "success"}
|
||||
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}
|
||||
label={edit.pending && !edit.path
|
||||
? t("message.fileEditPreparing", { defaultValue: "Preparing file edit…" })
|
||||
@@ -109,6 +200,16 @@ function FileEditRow({
|
||||
{failureDetail}
|
||||
</span>
|
||||
) : 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>
|
||||
);
|
||||
}
|
||||
@@ -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.")
|
||||
.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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
if (!edit || !edit.tool || (!edit.path && !edit.pending)) return null;
|
||||
const inferredStatus =
|
||||
@@ -417,10 +455,6 @@ function findFileEditTraceIndex(
|
||||
)
|
||||
) return i;
|
||||
}
|
||||
for (const event of candidate.toolEvents ?? []) {
|
||||
const key = toolEventFileEditKey(event);
|
||||
if (key && incomingToolEventKeys.has(key)) return i;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1040,16 +1074,15 @@ export function useNanobotStream(
|
||||
}
|
||||
setMessages((prev) => {
|
||||
let segmentId = eventSegmentId;
|
||||
const base = prev;
|
||||
const base = stripCoveredFileEditToolHintsFromMessages(prev, normalized, turn);
|
||||
const targetIndex = findFileEditTraceIndex(base, segmentId, normalized);
|
||||
if (targetIndex !== null) {
|
||||
const target = base[targetIndex];
|
||||
segmentId = target.activitySegmentId ?? segmentId ?? detachedActivitySegmentId();
|
||||
if (opensFileEditPhase) fileEditSegmentRef.current = segmentId;
|
||||
const cleanedTarget = stripCoveredFileEditToolHints(target, normalized);
|
||||
const merged: UIMessage = {
|
||||
...cleanedTarget,
|
||||
fileEdits: mergeFileEdits(cleanedTarget.fileEdits, normalized),
|
||||
...target,
|
||||
fileEdits: mergeFileEdits(target.fileEdits, normalized),
|
||||
activitySegmentId: segmentId,
|
||||
...turn,
|
||||
};
|
||||
|
||||
@@ -141,6 +141,7 @@
|
||||
"presetModel": "Preset model",
|
||||
"density": "Density",
|
||||
"activityMode": "Activity detail",
|
||||
"fileEditDisplay": "File edit display",
|
||||
"codeWrap": "Code wrapping",
|
||||
"brandLogos": "Brand logos",
|
||||
"maxResults": "Max results",
|
||||
@@ -187,6 +188,7 @@
|
||||
"presetModel": "Switch to Default to edit model and provider from the WebUI.",
|
||||
"density": "Stored only in this browser.",
|
||||
"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.",
|
||||
"brandLogos": "Show third-party provider and CLI logos in Settings.",
|
||||
"maxResults": "Results returned by each web_search call.",
|
||||
@@ -329,6 +331,9 @@
|
||||
"compact": "Compact",
|
||||
"auto": "Auto",
|
||||
"expanded": "Expanded",
|
||||
"summary": "Summary",
|
||||
"diff": "Diff",
|
||||
"collapsedDiff": "Collapsed diff",
|
||||
"on": "On",
|
||||
"off": "Off",
|
||||
"defaultPermission": "Default Permission",
|
||||
@@ -1038,7 +1043,15 @@
|
||||
"forkFromHere": "Fork",
|
||||
"copyReply": "Copy",
|
||||
"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": {
|
||||
"title": "Image preview",
|
||||
|
||||
@@ -121,6 +121,7 @@
|
||||
"presetModel": "Modelo del preajuste",
|
||||
"density": "Densidad",
|
||||
"activityMode": "Detalle de actividad",
|
||||
"fileEditDisplay": "Vista de edición de archivos",
|
||||
"codeWrap": "Ajuste de código",
|
||||
"maxResults": "Resultados máximos",
|
||||
"timeout": "Tiempo de espera",
|
||||
@@ -165,6 +166,7 @@
|
||||
"presetModel": "Cambia a Default para editar modelo y proveedor desde WebUI.",
|
||||
"density": "Solo se guarda en este navegador.",
|
||||
"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.",
|
||||
"maxResults": "Resultados devueltos por cada llamada web_search.",
|
||||
"timeout": "Segundos antes de que una solicitud de búsqueda expire.",
|
||||
@@ -214,6 +216,9 @@
|
||||
"compact": "Compacto",
|
||||
"auto": "Automático",
|
||||
"expanded": "Expandido",
|
||||
"summary": "Resumen",
|
||||
"diff": "Diff",
|
||||
"collapsedDiff": "Diff contraído",
|
||||
"on": "Activado",
|
||||
"off": "Desactivado",
|
||||
"defaultPermission": "Permiso predeterminado",
|
||||
@@ -1022,6 +1027,14 @@
|
||||
"copyReply": "Copiar",
|
||||
"copiedReply": "Copiado",
|
||||
"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}}",
|
||||
"activityThought": "Pensamiento completado",
|
||||
"activityThoughtFor": "Pensó durante {{duration}}",
|
||||
|
||||
@@ -121,6 +121,7 @@
|
||||
"presetModel": "Modèle du préréglage",
|
||||
"density": "Densité",
|
||||
"activityMode": "Détail d’activité",
|
||||
"fileEditDisplay": "Affichage des modifications de fichiers",
|
||||
"codeWrap": "Retour à la ligne du code",
|
||||
"maxResults": "Résultats max.",
|
||||
"timeout": "Délai d’attente",
|
||||
@@ -165,6 +166,7 @@
|
||||
"presetModel": "Passez à Default pour modifier le modèle et le fournisseur depuis WebUI.",
|
||||
"density": "Enregistré seulement dans ce navigateur.",
|
||||
"activityMode": "Choisissez le niveau de détail d’activité agent affiché par défaut.",
|
||||
"fileEditDisplay": "Choisissez si l’activité de modification affiche le nombre de lignes ou le diff.",
|
||||
"codeWrap": "Garde les longues lignes de code lisibles sur les petits écrans.",
|
||||
"maxResults": "Résultats renvoyés par chaque appel web_search.",
|
||||
"timeout": "Nombre de secondes avant l’expiration d’une requête de recherche.",
|
||||
@@ -214,6 +216,9 @@
|
||||
"compact": "Compacte",
|
||||
"auto": "Automatique",
|
||||
"expanded": "Développé",
|
||||
"summary": "Résumé",
|
||||
"diff": "Diff",
|
||||
"collapsedDiff": "Diff replié",
|
||||
"on": "Activé",
|
||||
"off": "Désactivé",
|
||||
"defaultPermission": "Autorisation par défaut",
|
||||
@@ -1022,6 +1027,14 @@
|
||||
"copyReply": "Copier",
|
||||
"copiedReply": "Copié",
|
||||
"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}}",
|
||||
"activityThought": "Réflexion terminée",
|
||||
"activityThoughtFor": "Réflexion terminée en {{duration}}",
|
||||
|
||||
@@ -121,6 +121,7 @@
|
||||
"presetModel": "Model preset",
|
||||
"density": "Kerapatan",
|
||||
"activityMode": "Detail aktivitas",
|
||||
"fileEditDisplay": "Tampilan edit file",
|
||||
"codeWrap": "Bungkus kode",
|
||||
"maxResults": "Hasil maksimum",
|
||||
"timeout": "Batas waktu",
|
||||
@@ -165,6 +166,7 @@
|
||||
"presetModel": "Beralih ke Default untuk mengedit model dan penyedia dari WebUI.",
|
||||
"density": "Hanya disimpan di browser ini.",
|
||||
"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.",
|
||||
"maxResults": "Resultados devueltos por cada llamada web_search.",
|
||||
"timeout": "Segundos antes de que una solicitud de búsqueda expire.",
|
||||
@@ -214,6 +216,9 @@
|
||||
"compact": "Ringkas",
|
||||
"auto": "Otomatis",
|
||||
"expanded": "Diperluas",
|
||||
"summary": "Ringkasan",
|
||||
"diff": "Diff",
|
||||
"collapsedDiff": "Diff diciutkan",
|
||||
"on": "Aktif",
|
||||
"off": "Nonaktif",
|
||||
"defaultPermission": "Izin default",
|
||||
@@ -1022,6 +1027,14 @@
|
||||
"copyReply": "Salin",
|
||||
"copiedReply": "Disalin",
|
||||
"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}}",
|
||||
"activityThought": "Selesai berpikir",
|
||||
"activityThoughtFor": "Selesai berpikir dalam {{duration}}",
|
||||
|
||||
@@ -121,6 +121,7 @@
|
||||
"presetModel": "プリセットモデル",
|
||||
"density": "表示密度",
|
||||
"activityMode": "アクティビティ詳細",
|
||||
"fileEditDisplay": "ファイル編集表示",
|
||||
"codeWrap": "コードの折り返し",
|
||||
"maxResults": "最大結果数",
|
||||
"timeout": "タイムアウト",
|
||||
@@ -165,6 +166,7 @@
|
||||
"presetModel": "Default に切り替えると、WebUI からモデルとプロバイダーを編集できます。",
|
||||
"density": "このブラウザーにのみ保存されます。",
|
||||
"activityMode": "既定で表示する agent アクティビティの詳細量を選択します。",
|
||||
"fileEditDisplay": "ファイル編集アクティビティを行数または差分で表示します。",
|
||||
"codeWrap": "小さな画面でも長いコード行を読みやすくします。",
|
||||
"maxResults": "各 web_search 呼び出しで返す結果数です。",
|
||||
"timeout": "検索プロバイダーのリクエストがタイムアウトするまでの秒数です。",
|
||||
@@ -214,6 +216,9 @@
|
||||
"compact": "コンパクト",
|
||||
"auto": "自動",
|
||||
"expanded": "展開",
|
||||
"summary": "概要",
|
||||
"diff": "差分",
|
||||
"collapsedDiff": "折りたたみ差分",
|
||||
"on": "オン",
|
||||
"off": "オフ",
|
||||
"defaultPermission": "既定の権限",
|
||||
@@ -1022,6 +1027,14 @@
|
||||
"copyReply": "コピー",
|
||||
"copiedReply": "コピー済み",
|
||||
"turnLatencyTitle": "応答時間(全行程)",
|
||||
"fileEditViewDiff": "差分を表示",
|
||||
"fileEditViewLargeDiff": "大きな差分を表示",
|
||||
"fileEditDiffLineCount": "{{count}} 行",
|
||||
"fileEditUnchangedLinesHidden": "未変更の {{count}} 行を非表示",
|
||||
"fileEditShowMoreLines": "さらに {{count}} 行を表示",
|
||||
"fileEditShowFewerLines": "表示行数を減らす",
|
||||
"fileEditOpenFile": "ファイルを開く",
|
||||
"fileEditDiffTruncated": "差分は切り詰められました。完全な変更はファイルを開いて確認してください。",
|
||||
"activityThinkingFor": "{{duration}}考えています",
|
||||
"activityThought": "思考しました",
|
||||
"activityThoughtFor": "{{duration}}考えました",
|
||||
|
||||
@@ -121,6 +121,7 @@
|
||||
"presetModel": "프리셋 모델",
|
||||
"density": "밀도",
|
||||
"activityMode": "활동 상세",
|
||||
"fileEditDisplay": "파일 편집 표시",
|
||||
"codeWrap": "코드 줄바꿈",
|
||||
"maxResults": "최대 결과 수",
|
||||
"timeout": "타임아웃",
|
||||
@@ -165,6 +166,7 @@
|
||||
"presetModel": "Default로 전환하면 WebUI에서 모델과 제공자를 편집할 수 있습니다.",
|
||||
"density": "이 브라우저에만 저장됩니다.",
|
||||
"activityMode": "기본으로 표시할 agent 활동 세부 수준을 선택합니다.",
|
||||
"fileEditDisplay": "파일 편집 활동을 줄 수 또는 diff로 표시할지 선택합니다.",
|
||||
"codeWrap": "작은 화면에서도 긴 코드 줄을 읽기 쉽게 유지합니다.",
|
||||
"maxResults": "각 web_search 호출에서 반환되는 결과 수입니다.",
|
||||
"timeout": "검색 제공자 요청이 타임아웃되기 전의 초입니다.",
|
||||
@@ -214,6 +216,9 @@
|
||||
"compact": "컴팩트",
|
||||
"auto": "자동",
|
||||
"expanded": "펼침",
|
||||
"summary": "요약",
|
||||
"diff": "Diff",
|
||||
"collapsedDiff": "접힌 diff",
|
||||
"on": "켜짐",
|
||||
"off": "꺼짐",
|
||||
"defaultPermission": "기본 권한",
|
||||
@@ -1022,6 +1027,14 @@
|
||||
"copyReply": "복사",
|
||||
"copiedReply": "복사됨",
|
||||
"turnLatencyTitle": "응답 시간(엔드투엔드)",
|
||||
"fileEditViewDiff": "Diff 보기",
|
||||
"fileEditViewLargeDiff": "큰 diff 보기",
|
||||
"fileEditDiffLineCount": "{{count}}줄",
|
||||
"fileEditUnchangedLinesHidden": "변경되지 않은 {{count}}줄 숨김",
|
||||
"fileEditShowMoreLines": "{{count}}줄 더 보기",
|
||||
"fileEditShowFewerLines": "줄 줄이기",
|
||||
"fileEditOpenFile": "파일 열기",
|
||||
"fileEditDiffTruncated": "Diff가 잘렸습니다. 전체 변경은 파일을 열어 확인하세요.",
|
||||
"activityThinkingFor": "{{duration}} 동안 생각 중",
|
||||
"activityThought": "생각함",
|
||||
"activityThoughtFor": "{{duration}} 동안 생각함",
|
||||
|
||||
@@ -121,6 +121,7 @@
|
||||
"presetModel": "Mô hình preset",
|
||||
"density": "Mật độ",
|
||||
"activityMode": "Chi tiết hoạt động",
|
||||
"fileEditDisplay": "Hiển thị sửa tệp",
|
||||
"codeWrap": "Xuống dòng mã",
|
||||
"maxResults": "Kết quả tối đa",
|
||||
"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.",
|
||||
"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.",
|
||||
"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ỏ.",
|
||||
"maxResults": "Resultados devueltos por cada llamada web_search.",
|
||||
"timeout": "Segundos antes de que una solicitud de búsqueda expire.",
|
||||
@@ -214,6 +216,9 @@
|
||||
"compact": "Gọn",
|
||||
"auto": "Tự động",
|
||||
"expanded": "Mở rộng",
|
||||
"summary": "Tóm tắt",
|
||||
"diff": "Diff",
|
||||
"collapsedDiff": "Diff thu gọn",
|
||||
"on": "Bật",
|
||||
"off": "Tắt",
|
||||
"defaultPermission": "Quyền mặc định",
|
||||
@@ -1022,6 +1027,14 @@
|
||||
"copyReply": "Sao chép",
|
||||
"copiedReply": "Đã sao chép",
|
||||
"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}}",
|
||||
"activityThought": "Đã suy nghĩ",
|
||||
"activityThoughtFor": "Đã suy nghĩ trong {{duration}}",
|
||||
|
||||
@@ -141,6 +141,7 @@
|
||||
"presetModel": "预设模型",
|
||||
"density": "密度",
|
||||
"activityMode": "活动详情",
|
||||
"fileEditDisplay": "文件编辑展示",
|
||||
"codeWrap": "代码换行",
|
||||
"brandLogos": "品牌 Logo",
|
||||
"maxResults": "最大结果数",
|
||||
@@ -187,6 +188,7 @@
|
||||
"presetModel": "切回 Default 后可在 WebUI 中编辑模型和提供商。",
|
||||
"density": "只保存在此浏览器中。",
|
||||
"activityMode": "选择默认显示多少 agent 活动细节。",
|
||||
"fileEditDisplay": "选择文件编辑活动默认显示行数还是差异。",
|
||||
"codeWrap": "让长代码行在小屏幕上也易读。",
|
||||
"brandLogos": "在设置中显示第三方提供商和 CLI 图标。",
|
||||
"maxResults": "每次 web_search 调用返回的结果数。",
|
||||
@@ -329,6 +331,9 @@
|
||||
"compact": "紧凑",
|
||||
"auto": "自动",
|
||||
"expanded": "展开",
|
||||
"summary": "摘要",
|
||||
"diff": "差异",
|
||||
"collapsedDiff": "折叠差异",
|
||||
"on": "开启",
|
||||
"off": "关闭",
|
||||
"defaultPermission": "默认权限",
|
||||
@@ -1038,7 +1043,15 @@
|
||||
"forkFromHere": "分叉",
|
||||
"copyReply": "复制",
|
||||
"copiedReply": "已复制",
|
||||
"turnLatencyTitle": "本轮耗时(端到端)"
|
||||
"turnLatencyTitle": "本轮耗时(端到端)",
|
||||
"fileEditViewDiff": "查看差异",
|
||||
"fileEditViewLargeDiff": "查看大型差异",
|
||||
"fileEditDiffLineCount": "{{count}} 行",
|
||||
"fileEditUnchangedLinesHidden": "已隐藏 {{count}} 行未修改内容",
|
||||
"fileEditShowMoreLines": "显示剩余 {{count}} 行",
|
||||
"fileEditShowFewerLines": "收起部分行",
|
||||
"fileEditOpenFile": "打开文件",
|
||||
"fileEditDiffTruncated": "差异内容已截断。打开文件可查看完整更改。"
|
||||
},
|
||||
"lightbox": {
|
||||
"title": "图片预览",
|
||||
|
||||
@@ -121,6 +121,7 @@
|
||||
"presetModel": "預設模型",
|
||||
"density": "密度",
|
||||
"activityMode": "活動細節",
|
||||
"fileEditDisplay": "檔案編輯顯示",
|
||||
"codeWrap": "程式碼換行",
|
||||
"maxResults": "最大結果數",
|
||||
"timeout": "逾時",
|
||||
@@ -165,6 +166,7 @@
|
||||
"presetModel": "切回 Default 後可在 WebUI 中編輯模型與供應商。",
|
||||
"density": "只儲存在此瀏覽器中。",
|
||||
"activityMode": "選擇預設顯示多少 agent 活動細節。",
|
||||
"fileEditDisplay": "選擇檔案編輯活動預設顯示行數或差異。",
|
||||
"codeWrap": "讓長程式碼行在小螢幕上也易讀。",
|
||||
"maxResults": "每次 web_search 呼叫返回的結果數。",
|
||||
"timeout": "搜尋供應商請求逾時前的秒數。",
|
||||
@@ -214,6 +216,9 @@
|
||||
"compact": "緊湊",
|
||||
"auto": "自動",
|
||||
"expanded": "展開",
|
||||
"summary": "摘要",
|
||||
"diff": "差異",
|
||||
"collapsedDiff": "摺疊差異",
|
||||
"on": "開啟",
|
||||
"off": "關閉",
|
||||
"defaultPermission": "預設權限",
|
||||
@@ -1022,6 +1027,14 @@
|
||||
"copyReply": "複製",
|
||||
"copiedReply": "已複製",
|
||||
"turnLatencyTitle": "本輪耗時(端到端)",
|
||||
"fileEditViewDiff": "查看差異",
|
||||
"fileEditViewLargeDiff": "查看大型差異",
|
||||
"fileEditDiffLineCount": "{{count}} 行",
|
||||
"fileEditUnchangedLinesHidden": "已隱藏 {{count}} 行未修改內容",
|
||||
"fileEditShowMoreLines": "顯示其餘 {{count}} 行",
|
||||
"fileEditShowFewerLines": "收起部分行",
|
||||
"fileEditOpenFile": "開啟檔案",
|
||||
"fileEditDiffTruncated": "差異內容已截斷。開啟檔案可查看完整更改。",
|
||||
"activityThinkingFor": "思考中,已 {{duration}}",
|
||||
"activityThought": "已思考",
|
||||
"activityThoughtFor": "已思考 {{duration}}",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -210,6 +210,13 @@ export interface ToolProgressEvent {
|
||||
embeds?: unknown[];
|
||||
}
|
||||
|
||||
export interface UIFileDiff {
|
||||
format: "unified" | string;
|
||||
context?: number;
|
||||
truncated?: boolean;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export interface UIFileEdit {
|
||||
version?: number;
|
||||
call_id: string;
|
||||
@@ -225,6 +232,7 @@ export interface UIFileEdit {
|
||||
binary?: boolean;
|
||||
error?: string;
|
||||
pending?: boolean;
|
||||
diff?: UIFileDiff;
|
||||
}
|
||||
|
||||
export interface ChatSummary {
|
||||
|
||||
@@ -41,6 +41,15 @@ const BROWSERBASE_MCP: McpPresetInfo = {
|
||||
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[] {
|
||||
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", () => {
|
||||
render(
|
||||
<AgentActivityCluster
|
||||
@@ -985,8 +1289,11 @@ describe("AgentActivityCluster", () => {
|
||||
expect(screen.queryByText(/\[Errno 13\]/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("merges repeated edits for the same path and lets successful edits win over failures", async () => {
|
||||
const restoreMotion = installReducedMotion();
|
||||
it("renders repeated edits for the same path as separate actions", () => {
|
||||
localStorage.setItem(
|
||||
"nanobot-webui.settings-preferences",
|
||||
JSON.stringify({ fileEditDisplayMode: "diff" }),
|
||||
);
|
||||
try {
|
||||
render(
|
||||
<AgentActivityCluster
|
||||
@@ -1006,6 +1313,13 @@ describe("AgentActivityCluster", () => {
|
||||
deleted: 1,
|
||||
approximate: false,
|
||||
status: "done",
|
||||
diff: unifiedFileDiff([
|
||||
"--- minecraft-fps/index.html",
|
||||
"+++ minecraft-fps/index.html",
|
||||
"@@ -1,1 +1,2 @@",
|
||||
" <main>",
|
||||
"+ <canvas />",
|
||||
]),
|
||||
},
|
||||
{
|
||||
call_id: "call-edit-2",
|
||||
@@ -1027,6 +1341,14 @@ describe("AgentActivityCluster", () => {
|
||||
deleted: 6,
|
||||
approximate: false,
|
||||
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,
|
||||
@@ -1036,20 +1358,24 @@ describe("AgentActivityCluster", () => {
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole("button", { name: /edited index\.html/i })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /failed index\.html/i })).not.toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole("button", { name: /edited index\.html/i }));
|
||||
const toggle = screen.getByRole("button", { name: "Edited 3 changes" });
|
||||
expect(toggle).toHaveTextContent("+8");
|
||||
expect(toggle).toHaveTextContent("-7");
|
||||
fireEvent.click(toggle);
|
||||
|
||||
const fileRefs = screen.getAllByTestId("activity-file-reference");
|
||||
expect(fileRefs).toHaveLength(1);
|
||||
expect(fileRefs[0]).toHaveTextContent("minecraft-fps/index.html");
|
||||
expect(screen.queryByText("Failed")).not.toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getAllByText("+8").length).toBeGreaterThan(0);
|
||||
expect(screen.getAllByText("-7").length).toBeGreaterThan(0);
|
||||
});
|
||||
expect(fileRefs).toHaveLength(3);
|
||||
expect(fileRefs.every((ref) => ref.textContent?.includes("minecraft-fps/index.html"))).toBe(true);
|
||||
expect(screen.getByText("patch failed")).toBeInTheDocument();
|
||||
expect(screen.getAllByTestId("file-edit-diff")).toHaveLength(2);
|
||||
expect(screen.getByText("<canvas />")).toBeInTheDocument();
|
||||
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 {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -81,6 +81,7 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [
|
||||
"settings.rows.language",
|
||||
"settings.rows.density",
|
||||
"settings.rows.activityMode",
|
||||
"settings.rows.fileEditDisplay",
|
||||
"settings.rows.codeWrap",
|
||||
"settings.rows.brandLogos",
|
||||
"settings.rows.currentModel",
|
||||
@@ -91,6 +92,7 @@ const LOCALIZED_SETTINGS_COPY_KEYS = [
|
||||
"settings.help.language",
|
||||
"settings.help.density",
|
||||
"settings.help.activityMode",
|
||||
"settings.help.fileEditDisplay",
|
||||
"settings.help.codeWrap",
|
||||
"settings.help.brandLogos",
|
||||
"settings.help.currentModel",
|
||||
|
||||
@@ -159,7 +159,7 @@ const installedAnyGen = {
|
||||
|
||||
function renderSettingsView(
|
||||
options: {
|
||||
initialSection?: "overview" | "apps" | "automations" | "advanced" | "models" | "browser";
|
||||
initialSection?: "overview" | "appearance" | "apps" | "automations" | "advanced" | "models" | "browser";
|
||||
initialSettings?: SettingsPayload;
|
||||
showSidebar?: boolean;
|
||||
onSettingsChange?: (payload: SettingsPayload) => void;
|
||||
@@ -185,10 +185,27 @@ function renderSettingsView(
|
||||
|
||||
describe("SettingsView Apps catalog", () => {
|
||||
afterEach(() => {
|
||||
localStorage.removeItem("nanobot-webui.settings-preferences");
|
||||
vi.useRealTimers();
|
||||
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 () => {
|
||||
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
|
||||
@@ -596,6 +596,71 @@ describe("useNanobotStream", () => {
|
||||
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", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-apply-patch-many", EMPTY_MESSAGES), {
|
||||
|
||||
Reference in New Issue
Block a user