refactor: remove verified dead code

This commit is contained in:
chengyongru
2026-08-08 21:10:34 +08:00
committed by chengyongru
parent 4e063f5695
commit 113e8d67ad
40 changed files with 76 additions and 716 deletions
-7
View File
@@ -140,10 +140,3 @@ class AutomationTurnCoordinator:
if pending_id:
pending_ids.add(pending_id)
return pending_ids
async def publish_next_deferred(self, session_key: str) -> bool:
return await publish_next_deferred_turn(
deferred_queues=self.deferred_queues,
publish_inbound=self._publish_inbound,
session_key=session_key,
)
-16
View File
@@ -785,22 +785,6 @@ def _best_window(old_text: str, content: str) -> tuple[float, int, list[str], li
return best_ratio, best_start, best_window_lines, hints
def _find_match(content: str, old_text: str) -> tuple[str | None, int]:
"""Locate old_text in content with a multi-level fallback chain:
1. Exact substring match
2. Line-trimmed sliding window (handles indentation differences)
3. Smart quote normalization (curly ↔ straight quotes)
Both inputs should use LF line endings (caller normalises CRLF).
Returns (matched_fragment, count) or (None, 0).
"""
matches = _find_matches(content, old_text)
if not matches:
return None, 0
return matches[0].text, len(matches)
@tool_parameters(
tool_parameters_schema(
path=StringSchema("The file path to edit"),
+1 -9
View File
@@ -3,15 +3,7 @@
from pathlib import Path
from nanobot.config.paths import get_media_dir
from nanobot.security.workspace_policy import (
is_path_within,
resolve_allowed_path,
)
def is_under(path: Path, directory: Path) -> bool:
"""Return True when path resolves under directory."""
return is_path_within(path, directory)
from nanobot.security.workspace_policy import resolve_allowed_path
def resolve_workspace_path(
-9
View File
@@ -12,15 +12,6 @@ def session_extra(metadata: Mapping[str, Any] | None) -> dict[str, Any]:
return {"cli_apps": cli_apps} if isinstance(cli_apps, list) and cli_apps else {}
def runtime_lines(message: Any, workspace: Path, *, skip: bool = False) -> list[str]:
"""Return model-visible CLI app annotations for the current turn."""
if skip:
return []
text = message.content if isinstance(getattr(message, "content", None), str) else ""
metadata = message.metadata if isinstance(getattr(message, "metadata", None), Mapping) else None
return runtime_lines_for_request(text, metadata, workspace)
def runtime_lines_for_request(
text: str,
metadata: Mapping[str, Any] | None,
-9
View File
@@ -470,15 +470,6 @@ def _extract_post_content(content_json: dict[str, Any]) -> tuple[str, list[str]]
return "", []
def _extract_post_text(content_json: dict[str, Any]) -> str: # pyright: ignore[reportUnusedFunction]
"""Extract plain text from Feishu post (rich text) message content.
Legacy wrapper for _extract_post_content, returns only text.
"""
text, _ = _extract_post_content(content_json)
return text
# =============================================================================
# QR scan-to-create onboarding
#
-8
View File
@@ -658,11 +658,6 @@ class MattermostChannel(BaseChannel):
resp.raise_for_status()
return cast(dict[str, Any], resp.json())
async def _api_put(self, path: str, json_data: dict[str, Any]) -> dict[str, Any]:
resp = await self._require_http_client().put(path, json=json_data)
resp.raise_for_status()
return cast(dict[str, Any], resp.json())
async def _create_post(
self,
channel_id: str,
@@ -681,9 +676,6 @@ class MattermostChannel(BaseChannel):
body["file_ids"] = file_ids
return await self._api_post("/api/v4/posts", body)
async def _edit_post(self, post_id: str, message: str) -> dict[str, Any]:
return await self._api_put(f"/api/v4/posts/{post_id}", {"id": post_id, "message": message})
async def _upload_file(self, channel_id: str, file_path: str) -> str | None:
path = Path(file_path)
if not path.exists():
-5
View File
@@ -811,11 +811,6 @@ class MSTeamsChannel(BaseChannel):
except Exception as e:
self.logger.warning("Failed to save conversation refs: {}", e)
def _save_refs(self, *, prune: bool = True) -> None:
"""Persist conversation references."""
with self._refs_guard:
self._save_refs_locked(prune=prune)
async def _get_access_token(self) -> str:
"""Fetch an access token for Bot Framework / Azure Bot auth."""
@@ -228,7 +228,8 @@ def test_save_prunes_unsupported_conversation_refs(make_channel, tmp_path, monke
),
}
ch._save_refs()
with ch._refs_guard:
ch._save_refs_locked()
assert set(ch._conversation_refs.keys()) == {"conv-valid"}
@@ -378,7 +379,8 @@ def test_save_uses_atomic_replace_and_keeps_existing_file_on_replace_error(make_
raise OSError("replace failed")
monkeypatch.setattr(msteams_module.os, "replace", _raise_replace)
ch._save_refs()
with ch._refs_guard:
ch._save_refs_locked()
persisted = json.loads(refs_path.read_text(encoding="utf-8"))
assert set(persisted.keys()) == {"conv-old"}
@@ -934,7 +936,8 @@ def test_save_refs_prunes_webchat_and_stale_refs(make_channel):
),
}
ch._save_refs()
with ch._refs_guard:
ch._save_refs_locked()
assert set(ch._conversation_refs) == {"teams-good"}
saved = json.loads(ch._refs_path.read_text(encoding="utf-8"))
-16
View File
@@ -30,7 +30,6 @@ from nanobot.bus.outbound_events import (
TurnEndEvent,
TurnModelUpdatedEvent,
outbound_event_from_message,
outbound_message_for_event,
)
from nanobot.bus.queue import MessageBus
from nanobot.channels.base import BaseChannel
@@ -280,21 +279,6 @@ class WebSocketConfig(Base):
)
def publish_runtime_model_update(
bus: MessageBus,
model: str,
model_preset: str | None,
) -> None:
"""Enqueue a runtime model snapshot for websocket subscribers (fan-out in-channel)."""
bus.outbound.put_nowait(
outbound_message_for_event(
channel="websocket",
chat_id="*",
event=RuntimeModelUpdatedEvent(model=model, model_preset=model_preset),
)
)
def _parse_inbound_payload(raw: str) -> str | None:
"""Parse a client frame into text; return None for empty or unrecognized content."""
text = raw.strip()
@@ -32,7 +32,6 @@ from nanobot.channels.websocket.runtime import (
_is_valid_chat_id,
_parse_envelope,
_parse_inbound_payload,
publish_runtime_model_update,
)
from nanobot.config.loader import load_config, save_config
from nanobot.config.schema import Config, ModelPresetConfig
@@ -1105,8 +1104,14 @@ async def test_send_broadcasts_runtime_model_updates() -> None:
mock_ws = AsyncMock()
channel._attach(mock_ws, "chat-1")
publish_runtime_model_update(bus, "openai/gpt-4.1", "fast")
await channel.send(bus.outbound.get_nowait())
await channel.send(
OutboundMessage(
channel="websocket",
chat_id="*",
content="",
event=RuntimeModelUpdatedEvent(model="openai/gpt-4.1", model_preset="fast"),
)
)
payload = json.loads(mock_ws.send.call_args[0][0])
assert payload["event"] == "runtime_model_updated"
@@ -1141,26 +1146,6 @@ async def test_send_scopes_turn_model_updates_to_the_subscribed_chat() -> None:
chat_two.send.assert_not_awaited()
@pytest.mark.asyncio
async def test_runtime_model_update_publisher_uses_websocket_outbound_event() -> None:
bus = MessageBus()
publish_runtime_model_update(
bus,
"openai/gpt-4.1",
"fast",
)
event = bus.outbound.get_nowait()
assert event.channel == "websocket"
assert event.chat_id == "*"
assert event.content == ""
assert event.metadata == {}
assert isinstance(event.event, RuntimeModelUpdatedEvent)
assert event.event.model == "openai/gpt-4.1"
assert event.event.model_preset == "fast"
@pytest.mark.asyncio
async def test_send_stages_external_media_as_signed_url(monkeypatch, tmp_path) -> None:
bus = MagicMock()
@@ -22,6 +22,7 @@ from nanobot.webui.gateway_services import build_gateway_services
from nanobot.webui.media_api import (
b64url_decode,
b64url_encode,
sign_media_path,
)
from .ws_test_client import InProcessHttpChannel
@@ -84,8 +85,16 @@ def _fake_media_dir(root: Path):
return inner
def _sign_media_path(channel: WebSocketChannel, path: Path) -> str | None:
return sign_media_path(
path,
secret=channel.gateway.media.secret,
media_dir=channel.gateway.media._media_dir,
)
# ---------------------------------------------------------------------------
# gateway.media.sign_media_path: the URL minter
# media_api.sign_media_path: the URL minter
# ---------------------------------------------------------------------------
@@ -105,10 +114,10 @@ def test_sign_media_path_rejects_paths_outside_media_root(
media.mkdir()
channel = _ch(bus, port=0)
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
assert channel.gateway.media.sign_media_path(outside) is None
assert _sign_media_path(channel, outside) is None
# Traversal via the media root is also rejected — the resolve() step
# normalises ``..`` out before the relative_to check.
assert channel.gateway.media.sign_media_path(media / ".." / "secrets" / "cred.txt") is None
assert _sign_media_path(channel, media / ".." / "secrets" / "cred.txt") is None
def test_sign_media_path_round_trips_via_hmac(
@@ -120,7 +129,7 @@ def test_sign_media_path_round_trips_via_hmac(
(media / "a.png").write_bytes(_PNG_BYTES)
channel = _ch(bus, port=0)
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
url = channel.gateway.media.sign_media_path(media / "a.png")
url = _sign_media_path(channel, media / "a.png")
assert url is not None
assert url.startswith("/api/media/")
sig, payload = url[len("/api/media/"):].split("/", 1)
@@ -235,7 +244,7 @@ async def test_media_route_serves_signed_file(
channel = _ch(bus, port=29920)
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
url_path = channel.gateway.media.sign_media_path(target)
url_path = _sign_media_path(channel, target)
assert url_path is not None
server_task = asyncio.create_task(channel.start())
try:
@@ -267,7 +276,7 @@ async def test_media_route_serves_video_byte_ranges(
channel = _ch(bus, port=29927)
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
url_path = channel.gateway.media.sign_media_path(target)
url_path = _sign_media_path(channel, target)
assert url_path is not None
server_task = asyncio.create_task(channel.start())
try:
@@ -298,7 +307,7 @@ async def test_media_route_serves_suffix_video_byte_ranges(
channel = _ch(bus, port=29928)
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
url_path = channel.gateway.media.sign_media_path(target)
url_path = _sign_media_path(channel, target)
assert url_path is not None
server_task = asyncio.create_task(channel.start())
try:
@@ -326,7 +335,7 @@ async def test_media_route_rejects_unsatisfiable_byte_range(
channel = _ch(bus, port=29929)
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
url_path = channel.gateway.media.sign_media_path(target)
url_path = _sign_media_path(channel, target)
assert url_path is not None
server_task = asyncio.create_task(channel.start())
try:
@@ -358,7 +367,7 @@ async def test_media_route_rejects_bad_signature(
channel = _ch(bus, port=29921)
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
good = channel.gateway.media.sign_media_path(media / "f.png")
good = _sign_media_path(channel, media / "f.png")
assert good is not None
_, payload = good[len("/api/media/"):].split("/", 1)
# Forge a sig with a *different* secret.
@@ -423,7 +432,7 @@ async def test_media_route_404s_missing_file(
channel = _ch(bus, port=29923)
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
url_path = channel.gateway.media.sign_media_path(target)
url_path = _sign_media_path(channel, target)
assert url_path is not None
target.unlink() # the file vanishes between signing and fetching
server_task = asyncio.create_task(channel.start())
@@ -480,7 +489,7 @@ async def test_media_route_serves_svg_with_strict_csp(
channel = _ch(bus, port=29928)
with patch("nanobot.webui.media_gateway.get_media_dir", return_value=media):
url_path = channel.gateway.media.sign_media_path(target)
url_path = _sign_media_path(channel, target)
assert url_path is not None
server_task = asyncio.create_task(channel.start())
try:
-7
View File
@@ -25,9 +25,6 @@ from nanobot.cron.types import (
CronSchedule,
CronStore,
)
from nanobot.utils.run_records import (
safe_run_record_name,
)
from nanobot.utils.run_records import (
write_run_record as write_automation_run_record,
)
@@ -440,10 +437,6 @@ class CronService:
tmp_path.unlink(missing_ok=True)
raise
@staticmethod
def _safe_run_record_name(run_id: str) -> str:
return safe_run_record_name(run_id)
def write_run_record(self, run_id: str, record: dict[str, Any]) -> None:
"""Write an internal audit record for one cron execution."""
write_automation_run_record(self._run_records_dir, run_id, record)
-6
View File
@@ -7,7 +7,6 @@ from typing import Any, Mapping
from nanobot.cron.types import CronJob
from nanobot.session.automation_turns import (
AutomationTurnSpec,
automation_history_overrides_for_spec,
automation_trigger,
)
@@ -63,11 +62,6 @@ def cron_run_id(metadata: Mapping[str, Any] | None) -> str | None:
return value if isinstance(value, str) and value else None
def cron_history_overrides(metadata: Mapping[str, Any] | None) -> tuple[str | None, dict[str, Any]]:
"""Return session-history text/metadata overrides for a cron turn."""
return automation_history_overrides_for_spec(metadata, CRON_AUTOMATION_SPEC)
def is_bound_cron_job(job: CronJob) -> bool:
"""True for session-bound cron jobs with complete delivery context."""
payload = job.payload
-4
View File
@@ -1086,10 +1086,6 @@ class SessionManager:
"""Attempt to recover a session from a corrupt JSONL file."""
return self._jsonl_store.repair(key, path=path)
@staticmethod
def _session_payload(session: Session) -> SessionPayload:
return JsonlSessionStore.session_payload(session)
def save(self, session: Session, *, fsync: bool = False) -> None:
"""Persist a session and retain it in the cache."""
archiver = self._file_cap_archiver
-11
View File
@@ -6,7 +6,6 @@ from typing import Any, Mapping
from nanobot.session.automation_turns import (
AutomationTurnSpec,
automation_history_overrides_for_spec,
automation_trigger,
)
@@ -50,13 +49,3 @@ def local_trigger_delivery_id(metadata: Mapping[str, Any] | None) -> str | None:
return None
value = trigger.get("delivery_id")
return value if isinstance(value, str) and value else None
def local_trigger_history_overrides(
metadata: Mapping[str, Any] | None,
) -> tuple[str | None, dict[str, Any]]:
"""Return session-history text/metadata overrides for a local trigger turn."""
return automation_history_overrides_for_spec(
metadata,
LOCAL_TRIGGER_AUTOMATION_SPEC,
)
-29
View File
@@ -11,35 +11,6 @@ from loguru import logger
from nanobot.utils.helpers import detect_image_mime
# Supported file extensions for text extraction
SUPPORTED_EXTENSIONS: set[str] = {
# Document formats
".pdf",
".docx",
".xlsx",
".pptx",
# Text formats
".txt",
".md",
".csv",
".json",
".xml",
".html",
".htm",
".log",
".yaml",
".yml",
".toml",
".ini",
".cfg",
# Image formats (for future OCR support)
".png",
".jpg",
".jpeg",
".gif",
".webp",
}
_MAX_TEXT_LENGTH = 200_000
_MAX_EXTRACT_FILE_SIZE = 50 * 1024 * 1024 # 50 MB
_MAX_OFFICE_ARCHIVE_MEMBERS = 10_000
-18
View File
@@ -274,24 +274,6 @@ def _text_line_count(text: str) -> int:
return line_count if last_was_newline else line_count + 1
def prepare_file_edit_tracker(
*,
call_id: str,
tool_name: str,
tool: Any,
workspace: Path | None,
params: dict[str, Any] | None,
) -> FileEditTracker | None:
trackers = prepare_file_edit_trackers(
call_id=call_id,
tool_name=tool_name,
tool=tool,
workspace=workspace,
params=params,
)
return trackers[0] if trackers else None
def prepare_file_edit_trackers(
*,
call_id: str,
+2 -56
View File
@@ -5,14 +5,13 @@ from __future__ import annotations
import io
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import TYPE_CHECKING, Iterable, cast
from typing import TYPE_CHECKING, cast
from loguru import logger
if TYPE_CHECKING:
from dulwich.objects import Blob, Commit, ObjectID, Tree, TreeEntry
from dulwich.objects import Blob, Commit, ObjectID, Tree
from dulwich.refs import Ref
from dulwich.repo import Repo
@@ -45,25 +44,6 @@ class CommitInfo:
return f"{header}\n(no file changes)"
@dataclass
class LineAge:
"""Age of a single line based on git blame."""
age_days: int # days since last modification
def _compute_line_ages(
annotated: Iterable[tuple[tuple["Commit", "TreeEntry"], bytes]],
) -> list[LineAge]:
"""Convert annotate results to per-line ages."""
now = datetime.now(tz=timezone.utc).date()
ages: list[LineAge] = []
for (commit, _tree_entry), _line_bytes in annotated:
dt = datetime.fromtimestamp(commit.commit_time, tz=timezone.utc).date()
ages.append(LineAge(age_days=(now - dt).days))
return ages
class GitStore:
"""Git-backed version control for memory files."""
@@ -293,33 +273,6 @@ class GitStore:
except Exception as exc:
raise GitStoreError("Git log failed") from exc
def line_ages(self, file_path: str) -> list[LineAge]:
"""Compute the age of each line in a tracked file via git blame.
Returns one LineAge per line, in order.
Returns an empty list if the repo is not initialized or the file is
empty. Annotation failures raise :class:`GitStoreError`.
"""
if not self.is_initialized():
return []
target = self._workspace / file_path
if not target.exists() or target.stat().st_size == 0:
return []
try:
from dulwich import porcelain
annotated = porcelain.annotate(str(self._workspace), file_path)
except Exception as exc:
raise GitStoreError(f"Git line annotation failed for {file_path}") from exc
if not annotated:
return []
return _compute_line_ages(annotated)
def diff_commits(self, sha1: str, sha2: str) -> str:
"""Show diff between two commits."""
if not self.is_initialized():
@@ -461,13 +414,6 @@ class GitStore:
commit = cast("Commit", commit_obj)
return cast("Tree", repo[commit.tree])
def find_commit(self, short_sha: str, max_entries: int = 20) -> CommitInfo | None:
"""Find a commit by short SHA prefix match."""
for c in self.log(max_entries=max_entries):
if c.sha.startswith(short_sha):
return c
return None
def show_commit_diff(
self,
short_sha: str,
-12
View File
@@ -351,18 +351,6 @@ def timestamp() -> str:
return datetime.now().isoformat()
def current_time_str(timezone: str | None = None) -> str:
"""Return the current time string."""
from zoneinfo import ZoneInfo
tz = ZoneInfo(timezone) if timezone else None
now = datetime.now(tz=tz) if tz else datetime.now().astimezone()
offset = now.strftime("%z")
offset_fmt = f"{offset[:3]}:{offset[3:]}" if len(offset) == 5 else offset
tz_name = timezone or (time.strftime("%Z") or "UTC")
return f"{now.strftime('%Y-%m-%d %H:%M (%A)')} ({tz_name}, UTC{offset_fmt})"
_UNSAFE_CHARS = re.compile(r'[<>:"/\\|?*]')
_TOOL_RESULT_PREVIEW_CHARS = 1200
_TOOL_RESULTS_DIR = ".nanobot/tool-results"
-8
View File
@@ -18,7 +18,6 @@ from nanobot.webui.attachment_ingress import (
from nanobot.webui.ingress_policy import AttachmentIngressLimits
from nanobot.webui.media_api import (
serve_signed_media,
sign_media_path,
sign_or_stage_media_path,
signed_media_attachments,
)
@@ -71,13 +70,6 @@ class WebUIMediaGateway:
media_dir=self._media_dir,
)
def sign_media_path(self, abs_path: Path) -> str | None:
return sign_media_path(
abs_path,
secret=self.secret,
media_dir=self._media_dir,
)
def sign_or_stage_media_path(self, path: Path) -> dict[str, str] | None:
return sign_or_stage_media_path(
path,
-29
View File
@@ -1313,21 +1313,6 @@ def _recover_incomplete_turns(
return recovered
def recover_incomplete_turns_from_session(
lines: list[dict[str, Any]],
session_messages: list[dict[str, Any]] | None,
*,
session_key: str,
) -> list[dict[str, Any]]:
"""Recover marked transcript answers only when one durable session turn matches."""
if not lines or not session_messages or not _needs_incomplete_turn_recovery(lines):
return lines
session_turns = _session_backfill_turns(session_key, session_messages)
if not session_turns:
return lines
return _recover_incomplete_turns(lines, session_turns)
def _with_backfilled_user(
records: list[dict[str, Any]],
user_event: dict[str, Any],
@@ -1365,20 +1350,6 @@ def _inject_missing_user_events(
return out
def inject_missing_user_events_from_session(
session_key: str,
lines: list[dict[str, Any]],
session_messages: list[dict[str, Any]] | None,
) -> list[dict[str, Any]]:
"""Backfill user rows for legacy WebUI transcripts that only stored assistant streams."""
if not lines or not session_messages or not _needs_user_event_backfill(lines):
return lines
session_turns = _session_backfill_turns(session_key, session_messages)
if not session_turns:
return lines
return _inject_missing_user_events(lines, session_turns)
def _format_tool_call_trace(call: Any) -> str | None:
if not call or not isinstance(call, dict):
return None
-13
View File
@@ -169,19 +169,6 @@ class TestDiffCommits:
assert git_ready.diff_commits("deadbeef", "cafebabe") == ""
class TestFindCommit:
def test_finds_by_prefix(self, git_ready):
ws = git_ready._workspace
(ws / "SOUL.md").write_text("v2", encoding="utf-8")
sha = git_ready.auto_commit("v2")
found = git_ready.find_commit(sha[:4])
assert found is not None
assert found.sha == sha
def test_returns_none_for_unknown(self, git_ready):
assert git_ready.find_commit("deadbeef") is None
class TestShowCommitDiff:
def test_returns_commit_with_diff(self, git_ready):
ws = git_ready._workspace
+11 -2
View File
@@ -9,6 +9,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from agent.runner_helpers import make_run_spec
from nanobot.agent.automation_turns import publish_next_deferred_turn
from nanobot.config.schema import AgentDefaults
from nanobot.providers.base import LLMResponse, ToolCallRequest
@@ -1047,7 +1048,11 @@ async def test_cron_turn_deferred_while_session_active(tmp_path):
assert loop._cron_turns.deferred_queues[session_key] == [msg]
assert loop.pending_cron_job_ids_for_session(session_key) == {"job-1"}
await loop._cron_turns.publish_next_deferred(session_key)
await publish_next_deferred_turn(
deferred_queues=loop._cron_turns.deferred_queues,
publish_inbound=loop.bus.publish_inbound,
session_key=session_key,
)
queued = await asyncio.wait_for(loop.bus.consume_inbound(), timeout=0.5)
assert queued is msg
assert session_key not in loop._cron_turns.deferred_queues
@@ -1097,7 +1102,11 @@ async def test_local_trigger_turn_deferred_while_session_active(tmp_path):
assert loop._local_trigger_turns.deferred_queues[session_key] == [msg]
assert loop.pending_local_trigger_ids_for_session(session_key) == {"trg_123"}
assert await loop._local_trigger_turns.publish_next_deferred(session_key) is True
assert await publish_next_deferred_turn(
deferred_queues=loop._local_trigger_turns.deferred_queues,
publish_inbound=loop.bus.publish_inbound,
session_key=session_key,
) is True
queued = await asyncio.wait_for(loop.bus.consume_inbound(), timeout=0.5)
assert queued is msg
assert session_key not in loop._local_trigger_turns.deferred_queues
+13 -16
View File
@@ -1,9 +1,7 @@
"""Tests for CLI Apps loop helpers."""
from types import SimpleNamespace
from nanobot.apps.cli.service import CliAppManager
from nanobot.apps.cli.utils import runtime_lines, session_extra
from nanobot.apps.cli.utils import runtime_lines_for_request, session_extra
def test_session_extra_returns_cli_apps_only_when_present() -> None:
@@ -30,8 +28,9 @@ def test_cli_app_mentions_inject_runtime_metadata(tmp_path, monkeypatch):
}
)
lines = runtime_lines(
SimpleNamespace(content="please use @zoom tonight; ignore @krita?", metadata={}),
lines = runtime_lines_for_request(
"please use @zoom tonight; ignore @krita?",
{},
tmp_path,
)
@@ -43,17 +42,15 @@ def test_cli_app_mentions_inject_runtime_metadata(tmp_path, monkeypatch):
def test_structured_cli_app_attachment_injects_runtime_metadata(tmp_path):
lines = runtime_lines(
SimpleNamespace(
content="please use @zoom tonight",
metadata={
"cli_apps": [{
"name": "zoom",
"entry_point": "cli-anything-zoom",
"display_name": "Zoom",
}],
},
),
lines = runtime_lines_for_request(
"please use @zoom tonight",
{
"cli_apps": [{
"name": "zoom",
"entry_point": "cli-anything-zoom",
"display_name": "Zoom",
}],
},
tmp_path,
)
-26
View File
@@ -6,7 +6,6 @@ from zipfile import ZipFile
import pytest
from nanobot.utils.document import (
SUPPORTED_EXTENSIONS,
PdfSafetyError,
_is_text_extension,
extract_pdf_pages,
@@ -14,31 +13,6 @@ from nanobot.utils.document import (
)
class TestSupportedExtensions:
"""Test the SUPPORTED_EXTENSIONS constant."""
def test_supported_extensions_include_common_formats(self):
"""Test that common document formats are included."""
# Document formats
assert ".pdf" in SUPPORTED_EXTENSIONS
assert ".docx" in SUPPORTED_EXTENSIONS
assert ".xlsx" in SUPPORTED_EXTENSIONS
assert ".pptx" in SUPPORTED_EXTENSIONS
# Text formats
assert ".txt" in SUPPORTED_EXTENSIONS
assert ".md" in SUPPORTED_EXTENSIONS
assert ".csv" in SUPPORTED_EXTENSIONS
assert ".json" in SUPPORTED_EXTENSIONS
assert ".yaml" in SUPPORTED_EXTENSIONS
assert ".yml" in SUPPORTED_EXTENSIONS
# Image formats
assert ".png" in SUPPORTED_EXTENSIONS
assert ".jpg" in SUPPORTED_EXTENSIONS
assert ".jpeg" in SUPPORTED_EXTENSIONS
class TestExtractText:
"""Test the extract_text function."""
+1 -36
View File
@@ -13,7 +13,7 @@ import os
import pytest
from nanobot.agent.tools import file_state
from nanobot.agent.tools.filesystem import EditFileTool, ReadFileTool, _find_match
from nanobot.agent.tools.filesystem import EditFileTool, ReadFileTool
@pytest.fixture(autouse=True)
@@ -68,41 +68,6 @@ class TestDeleteLineCleanup:
# ---------------------------------------------------------------------------
class TestSmartQuoteNormalization:
"""_find_match should handle curly ↔ straight quote fallback."""
def test_curly_double_quotes_match_straight(self):
content = 'She said \u201chello\u201d to him'
old_text = 'She said "hello" to him'
match, count = _find_match(content, old_text)
assert match is not None
assert count == 1
# Returned match should be the ORIGINAL content with curly quotes
assert "\u201c" in match
def test_curly_single_quotes_match_straight(self):
content = "it\u2019s a test"
old_text = "it's a test"
match, count = _find_match(content, old_text)
assert match is not None
assert count == 1
assert "\u2019" in match
def test_straight_matches_curly_in_old_text(self):
content = 'x = "hello"'
old_text = 'x = \u201chello\u201d'
match, count = _find_match(content, old_text)
assert match is not None
assert count == 1
def test_exact_match_still_preferred_over_quote_normalization(self):
content = 'x = "hello"'
old_text = 'x = "hello"'
match, count = _find_match(content, old_text)
assert match == old_text
assert count == 1
class TestQuoteStylePreservation:
"""When quote-normalized matching occurs, replacement should preserve actual quote style."""
-47
View File
@@ -7,7 +7,6 @@ from nanobot.agent.tools.filesystem import (
ListDirTool,
ReadFileTool,
WriteFileTool,
_find_match,
)
# ---------------------------------------------------------------------------
@@ -116,52 +115,6 @@ class TestReadFileTool:
assert "Maximum is 100 MiB" in result
# ---------------------------------------------------------------------------
# _find_match (unit tests for the helper)
# ---------------------------------------------------------------------------
class TestFindMatch:
def test_exact_match(self):
match, count = _find_match("hello world", "world")
assert match == "world"
assert count == 1
def test_exact_no_match(self):
match, count = _find_match("hello world", "xyz")
assert match is None
assert count == 0
def test_crlf_normalisation(self):
# Caller normalises CRLF before calling _find_match, so test with
# pre-normalised content to verify exact match still works.
content = "line1\nline2\nline3"
old_text = "line1\nline2\nline3"
match, count = _find_match(content, old_text)
assert match is not None
assert count == 1
def test_line_trim_fallback(self):
content = " def foo():\n pass\n"
old_text = "def foo():\n pass"
match, count = _find_match(content, old_text)
assert match is not None
assert count == 1
# The returned match should be the *original* indented text
assert " def foo():" in match
def test_line_trim_multiple_candidates(self):
content = " a\n b\n a\n b\n"
old_text = "a\nb"
match, count = _find_match(content, old_text)
assert count == 2
def test_empty_old_text(self):
match, count = _find_match("hello", "")
# Empty string is always "in" any string via exact match
assert match == ""
# ---------------------------------------------------------------------------
# EditFileTool
# ---------------------------------------------------------------------------
+11 -16
View File
@@ -9,7 +9,6 @@ from nanobot.utils.file_edit_events import (
build_file_edit_start_event,
build_unified_diff_payload,
line_diff_stats,
prepare_file_edit_tracker,
prepare_file_edit_trackers,
read_file_snapshot,
)
@@ -44,15 +43,14 @@ def test_write_file_start_tracks_snapshot_and_end_emits_exact_diff(tmp_path: Pat
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(
trackers = prepare_file_edit_trackers(
call_id="call-write",
tool_name="write_file",
tool=_write_tool(tmp_path),
workspace=tmp_path,
params=params,
)
assert tracker is not None
[tracker] = trackers
start = build_file_edit_start_event(tracker)
assert start == {
"version": 1,
@@ -103,15 +101,14 @@ def test_unified_diff_payload_truncates_large_diffs() -> None:
def test_binary_file_is_reported_but_not_counted(tmp_path: Path) -> None:
target = tmp_path / "data.bin"
target.write_bytes(b"\x00\x01before")
tracker = prepare_file_edit_tracker(
trackers = prepare_file_edit_trackers(
call_id="call-bin",
tool_name="edit_file",
tool=_edit_tool(tmp_path),
workspace=tmp_path,
params={"path": "data.bin", "old_text": "before", "new_text": "after"},
)
assert tracker is not None
[tracker] = trackers
assert not read_file_snapshot(target).countable
target.write_bytes(b"\x00\x01after")
event = build_file_edit_end_event(tracker)
@@ -123,15 +120,14 @@ def test_binary_file_is_reported_but_not_counted(tmp_path: Path) -> None:
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(
trackers = prepare_file_edit_trackers(
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
[tracker] = trackers
target.write_text("after\n", encoding="utf-8")
event = build_file_edit_end_event(tracker)
assert event["binary"] is True
@@ -215,15 +211,14 @@ def test_apply_patch_dry_run_does_not_prepare_file_edit_trackers(tmp_path: Path)
def test_oversized_file_is_reported_but_not_counted(tmp_path: Path) -> None:
target = tmp_path / "large.txt"
params = {"path": "large.txt", "content": "x"}
tracker = prepare_file_edit_tracker(
trackers = prepare_file_edit_trackers(
call_id="call-large",
tool_name="write_file",
tool=_write_tool(tmp_path),
workspace=tmp_path,
params=params,
)
assert tracker is not None
[tracker] = trackers
target.write_text("x" * (2 * 1024 * 1024 + 1), encoding="utf-8")
event = build_file_edit_end_event(tracker)
assert event["binary"] is True
@@ -232,11 +227,11 @@ def test_oversized_file_is_reported_but_not_counted(tmp_path: Path) -> None:
assert "diff" not in event
def test_untracked_tools_do_not_prepare_file_edit_tracker(tmp_path: Path) -> None:
assert prepare_file_edit_tracker(
def test_untracked_tools_do_not_prepare_file_edit_trackers(tmp_path: Path) -> None:
assert prepare_file_edit_trackers(
call_id="call-exec",
tool_name="exec",
tool=None,
workspace=tmp_path,
params={"path": "created-by-shell.txt"},
) is None
) == []
+2 -86
View File
@@ -1,13 +1,12 @@
"""Tests for GitStore — line_ages() and core git operations."""
"""Tests for GitStore core operations."""
import subprocess
from datetime import datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import patch
import pytest
from nanobot.utils.gitstore import GitStore, GitStoreError
from nanobot.utils.gitstore import GitStore
@pytest.fixture
@@ -18,89 +17,6 @@ def git(tmp_path):
return g
class TestLineAges:
def test_returns_empty_when_not_initialized(self, tmp_path):
"""line_ages should return [] if the git repo is not initialized."""
git = GitStore(tmp_path, tracked_files=["MEMORY.md"])
assert git.line_ages("MEMORY.md") == []
def test_returns_empty_for_missing_file(self, git):
"""line_ages should return [] for a file that doesn't exist."""
assert git.line_ages("SOUL.md") == []
def test_returns_empty_for_empty_file(self, git, tmp_path):
"""line_ages should return [] for an empty tracked file."""
(tmp_path / "SOUL.md").write_text("", encoding="utf-8")
git.auto_commit("empty soul")
assert git.line_ages("SOUL.md") == []
def test_one_age_per_line(self, git, tmp_path):
"""line_ages should return one entry per line in the file."""
content = "# Memory\n\n## Section A\n- item 1\n"
(tmp_path / "MEMORY.md").write_text(content, encoding="utf-8")
git.auto_commit("initial")
ages = git.line_ages("MEMORY.md")
assert len(ages) == len(content.splitlines())
def test_fresh_lines_have_age_zero(self, git, tmp_path):
"""Lines committed today should have age_days=0."""
(tmp_path / "MEMORY.md").write_text("## A\n- x\n", encoding="utf-8")
git.auto_commit("initial")
ages = git.line_ages("MEMORY.md")
assert all(a.age_days == 0 for a in ages)
def test_age_differentiates_across_days(self, git, tmp_path):
"""Lines committed today should show correct age when 'now' is mocked forward."""
(tmp_path / "MEMORY.md").write_text("## A\n- x\n", encoding="utf-8")
git.auto_commit("initial")
future_now = datetime.now(tz=timezone.utc) + timedelta(days=30)
with patch("nanobot.utils.gitstore.datetime") as mock_dt:
mock_dt.now.return_value = future_now
mock_dt.fromtimestamp = datetime.fromtimestamp
ages = git.line_ages("MEMORY.md")
assert len(ages) == 2
assert all(a.age_days == 30 for a in ages)
def test_annotate_failure_is_explicit(self, git, tmp_path):
(tmp_path / "MEMORY.md").write_text("important\n", encoding="utf-8")
git.auto_commit("initial")
with patch("dulwich.porcelain.annotate", side_effect=OSError("broken repo")):
with pytest.raises(GitStoreError, match="annotation failed"):
git.line_ages("MEMORY.md")
def test_partial_edit_only_updates_changed_lines(self, git, tmp_path):
"""Only modified lines should reflect the new commit's timestamp."""
now = datetime(2026, 5, 1, tzinfo=timezone.utc)
old = now - timedelta(days=30)
(tmp_path / "MEMORY.md").write_text(
"# Memory\n\n## A\n- old\n\n## B\n- keep\n", encoding="utf-8"
)
with patch("dulwich.worktree.time.time", return_value=old.timestamp()):
git.auto_commit("commit1")
# Only modify section A
(tmp_path / "MEMORY.md").write_text(
"# Memory\n\n## A\n- new\n\n## B\n- keep\n", encoding="utf-8"
)
with patch("dulwich.worktree.time.time", return_value=now.timestamp()):
git.auto_commit("commit2")
with patch("nanobot.utils.gitstore.datetime") as mock_dt:
mock_dt.now.return_value = now
mock_dt.fromtimestamp = datetime.fromtimestamp
ages = git.line_ages("MEMORY.md")
lines = (tmp_path / "MEMORY.md").read_text(encoding="utf-8").splitlines()
assert len(ages) == len(lines)
age_by_line = {line: age.age_days for line, age in zip(lines, ages, strict=True)}
assert age_by_line["- new"] == 0
assert age_by_line["- keep"] == 30
class TestSummarizeWorkingTree:
"""Ground-truth diff summary used to keep Dream audit records honest."""
-8
View File
@@ -1,14 +1,11 @@
from pathlib import Path
from zoneinfo import ZoneInfoNotFoundError
import pytest
import tiktoken
from nanobot.utils import helpers
from nanobot.utils.helpers import (
_write_text_atomic,
content_with_media_breadcrumbs,
current_time_str,
split_message,
truncate_text_to_tokens,
)
@@ -51,11 +48,6 @@ def test_truncate_text_to_tokens_non_positive_budget_returns_text():
assert truncate_text_to_tokens(text, 0) == text
def test_current_time_str_rejects_unknown_timezone():
with pytest.raises(ZoneInfoNotFoundError):
current_time_str("Not/AZone")
def test_content_with_media_breadcrumbs_preserves_valid_paths():
assert content_with_media_breadcrumbs(
"user",
-5
View File
@@ -9,7 +9,6 @@
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-popover": "1.1.15",
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.6",
"class-variance-authority": "^0.7.1",
@@ -250,8 +249,6 @@
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
"@radix-ui/react-separator": ["@radix-ui/react-separator@1.1.8", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-sDvqVY4itsKwwSMEe0jtKgfTh+72Sy3gPmQpjqcQneqQ4PFmr/1I0YA+2/puilhggCe2gJcx5EBAYFkWkdpa5g=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
"@radix-ui/react-tooltip": ["@radix-ui/react-tooltip@1.2.8", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-visually-hidden": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-tY7sVt1yL9ozIxvmbtN5qtmH2krXcBCfjEiCgKGLqunJHvgvZG2Pcl2oQ3kbcZARb1BGEHdkLzcYGO8ynVlieg=="],
@@ -1332,8 +1329,6 @@
"@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-separator/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="],
"@radix-ui/react-tooltip/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="],
-1
View File
@@ -16,7 +16,6 @@
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-popover": "1.1.15",
"@radix-ui/react-separator": "^1.1.1",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-tooltip": "^1.1.6",
"class-variance-authority": "^0.7.1",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

@@ -59,16 +59,6 @@ function truncatePreview(text: string, maxLength: number): string {
return text.length > maxLength ? `${text.slice(0, maxLength - 3)}...` : text;
}
export function jumpToPrompt(scrollEl: HTMLElement | null, promptId: string | undefined): void {
if (!scrollEl || !promptId) return;
const target = findPromptElement(scrollEl, promptId);
if (!target) return;
scrollEl.scrollTo({
top: Math.max(0, promptTop(scrollEl, target) - 16),
behavior: "smooth",
});
}
export function findPromptElement(scrollEl: HTMLElement, promptId: string): HTMLElement | null {
const candidates = scrollEl.querySelectorAll<HTMLElement>("[data-user-prompt-id]");
return Array.from(candidates).find(
+1 -73
View File
@@ -1,6 +1,6 @@
import * as React from "react";
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
import { Check, ChevronRight, Circle } from "lucide-react";
import { Circle } from "lucide-react";
import {
floatingItemClassName,
@@ -12,52 +12,11 @@ import { cn } from "@/lib/utils";
const DropdownMenu = DropdownMenuPrimitive.Root;
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger;
const DropdownMenuGroup = DropdownMenuPrimitive.Group;
const DropdownMenuPortal = DropdownMenuPrimitive.Portal;
const DropdownMenuSub = DropdownMenuPrimitive.Sub;
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup;
const menuItemClassName =
`${floatingItemClassName} ${floatingItemFocusClassName} cursor-default data-[disabled]:pointer-events-none data-[disabled]:opacity-50`;
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean;
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
menuItemClassName,
"data-[state=open]:bg-foreground/[0.055] dark:data-[state=open]:bg-white/[0.08]",
inset && "pl-8",
className,
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-3.5 w-3.5 text-muted-foreground" />
</DropdownMenuPrimitive.SubTrigger>
));
DropdownMenuSubTrigger.displayName = DropdownMenuPrimitive.SubTrigger.displayName;
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
floatingSurfaceClassName,
"max-h-[min(var(--radix-dropdown-menu-content-available-height),28rem)] min-w-[10rem]",
className,
)}
{...props}
/>
));
DropdownMenuSubContent.displayName = DropdownMenuPrimitive.SubContent.displayName;
interface DropdownMenuContentProps
extends React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content> {
portalContainer?: HTMLElement | null;
@@ -103,31 +62,6 @@ const DropdownMenuItem = React.forwardRef<
));
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
menuItemClassName,
"pl-8 pr-2.5",
className,
)}
checked={checked}
{...props}
>
<span className="absolute left-2.5 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-3.5 w-3.5" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
));
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName;
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
@@ -183,17 +117,11 @@ DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName;
export {
DropdownMenu,
DropdownMenuCheckboxItem,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuPortal,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
};
-29
View File
@@ -1,29 +0,0 @@
import * as React from "react";
import * as SeparatorPrimitive from "@radix-ui/react-separator";
import { cn } from "@/lib/utils";
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref,
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className,
)}
{...props}
/>
),
);
Separator.displayName = SeparatorPrimitive.Root.displayName;
export { Separator };
-51
View File
@@ -235,10 +235,6 @@
height: 0;
}
.shadow-inner-right {
box-shadow: inset -9px 0 6px -1px rgb(0 0 0 / 0.02);
}
/* Keep the outer document rhythm clean at message boundaries. */
.markdown-content > :first-child {
@apply mt-0;
@@ -338,7 +334,6 @@
animation: none;
content: "";
}
.markdown-content-streaming > :last-child::after,
.streaming-text-fallback::after {
animation: none;
}
@@ -565,52 +560,6 @@
}
}
@keyframes cli-app-linked-sheen {
0% {
transform: translateX(-140%) skewX(-14deg);
opacity: 0;
}
18% {
opacity: 0.7;
}
72%,
100% {
transform: translateX(140%) skewX(-14deg);
opacity: 0;
}
}
.cli-app-linked-chip::after {
content: "";
position: absolute;
inset: -1px;
pointer-events: none;
background: linear-gradient(
90deg,
transparent 0%,
hsl(var(--foreground) / 0.14) 46%,
hsl(var(--background) / 0.7) 50%,
hsl(var(--foreground) / 0.12) 54%,
transparent 100%
);
animation: cli-app-linked-sheen 1.25s ease-out 1;
}
.dark .cli-app-linked-chip::after {
background: linear-gradient(
90deg,
transparent 0%,
hsl(var(--foreground) / 0.12) 46%,
hsl(var(--background) / 0.5) 50%,
hsl(var(--foreground) / 0.1) 54%,
transparent 100%
);
}
@media (prefers-reduced-motion: reduce) {
.cli-app-linked-chip::after {
animation: none;
content: none;
}
}
/* Subtle scrollbar that doesn't fight the dark background. */
.scrollbar-thin {
scrollbar-width: thin;
-1
View File
@@ -55,7 +55,6 @@ export type AttachmentError =
| "io"; // file read failed at the browser layer
export const MAX_ATTACHMENTS_PER_MESSAGE = 4;
export const MAX_IMAGES_PER_MESSAGE = MAX_ATTACHMENTS_PER_MESSAGE;
export const MAX_ATTACHMENT_BYTES = 6 * 1024 * 1024;
export const MAX_TOTAL_ATTACHMENT_BYTES = 24 * 1024 * 1024;