mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-06 17:38:35 +00:00
fix(webui): prevent redundant thread and media reloads (#5164)
This commit is contained in:
parent
fc73d5ff39
commit
11fcd9cc5f
@ -67,6 +67,7 @@ from nanobot.webui.http_utils import (
|
|||||||
from nanobot.webui.mcp_presets_api import normalize_mcp_preset_mentions
|
from nanobot.webui.mcp_presets_api import normalize_mcp_preset_mentions
|
||||||
from nanobot.webui.metadata import (
|
from nanobot.webui.metadata import (
|
||||||
WEBSOCKET_TURN_OWNER_METADATA_KEY,
|
WEBSOCKET_TURN_OWNER_METADATA_KEY,
|
||||||
|
WEBUI_SYSTEM_COMMAND_TURN_PREFIX,
|
||||||
WEBUI_TURN_METADATA_KEY,
|
WEBUI_TURN_METADATA_KEY,
|
||||||
)
|
)
|
||||||
from nanobot.webui.transcript import WEBUI_TRANSCRIPT_INCOMPLETE_KEY
|
from nanobot.webui.transcript import WEBUI_TRANSCRIPT_INCOMPLETE_KEY
|
||||||
@ -1003,6 +1004,13 @@ class WebSocketChannel(BaseChannel):
|
|||||||
return
|
return
|
||||||
# Signal that the agent has fully finished processing the current turn.
|
# Signal that the agent has fully finished processing the current turn.
|
||||||
if isinstance(event, TurnEndEvent):
|
if isinstance(event, TurnEndEvent):
|
||||||
|
turn_id = (msg.metadata or {}).get(WEBUI_TURN_METADATA_KEY)
|
||||||
|
session_update_scope = (
|
||||||
|
"metadata"
|
||||||
|
if isinstance(turn_id, str)
|
||||||
|
and turn_id.startswith(WEBUI_SYSTEM_COMMAND_TURN_PREFIX)
|
||||||
|
else "thread"
|
||||||
|
)
|
||||||
turn_owner = (msg.metadata or {}).get(WEBSOCKET_TURN_OWNER_METADATA_KEY)
|
turn_owner = (msg.metadata or {}).get(WEBSOCKET_TURN_OWNER_METADATA_KEY)
|
||||||
await self.send_turn_end(
|
await self.send_turn_end(
|
||||||
msg.chat_id,
|
msg.chat_id,
|
||||||
@ -1011,7 +1019,7 @@ class WebSocketChannel(BaseChannel):
|
|||||||
metadata=msg.metadata,
|
metadata=msg.metadata,
|
||||||
turn_owner=turn_owner if isinstance(turn_owner, str) else None,
|
turn_owner=turn_owner if isinstance(turn_owner, str) else None,
|
||||||
)
|
)
|
||||||
await self.send_session_updated(msg.chat_id, scope="thread")
|
await self.send_session_updated(msg.chat_id, scope=session_update_scope)
|
||||||
return
|
return
|
||||||
if isinstance(event, SessionUpdatedEvent):
|
if isinstance(event, SessionUpdatedEvent):
|
||||||
if conns:
|
if conns:
|
||||||
|
|||||||
@ -49,7 +49,11 @@ from nanobot.webui.http_utils import (
|
|||||||
from nanobot.webui.http_utils import (
|
from nanobot.webui.http_utils import (
|
||||||
parse_request_path as _parse_request_path,
|
parse_request_path as _parse_request_path,
|
||||||
)
|
)
|
||||||
from nanobot.webui.metadata import WEBSOCKET_TURN_OWNER_METADATA_KEY
|
from nanobot.webui.metadata import (
|
||||||
|
WEBSOCKET_TURN_OWNER_METADATA_KEY,
|
||||||
|
WEBUI_SYSTEM_COMMAND_TURN_PREFIX,
|
||||||
|
WEBUI_TURN_METADATA_KEY,
|
||||||
|
)
|
||||||
from nanobot.webui.settings_api import settings_payload, update_provider_settings
|
from nanobot.webui.settings_api import settings_payload, update_provider_settings
|
||||||
from nanobot.webui.transcript import (
|
from nanobot.webui.transcript import (
|
||||||
append_transcript_object,
|
append_transcript_object,
|
||||||
@ -1618,6 +1622,43 @@ async def test_send_turn_end_emits_turn_end_event() -> None:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_system_command_turn_end_only_refreshes_session_metadata() -> None:
|
||||||
|
bus = MagicMock()
|
||||||
|
channel = WebSocketChannel(
|
||||||
|
{"enabled": True, "allowFrom": ["*"]},
|
||||||
|
bus,
|
||||||
|
gateway=_basic_handler(bus),
|
||||||
|
)
|
||||||
|
mock_ws = AsyncMock()
|
||||||
|
channel._attach(mock_ws, "chat-model")
|
||||||
|
|
||||||
|
await channel.send(OutboundMessage(
|
||||||
|
channel="websocket",
|
||||||
|
chat_id="chat-model",
|
||||||
|
content="",
|
||||||
|
metadata={
|
||||||
|
WEBUI_TURN_METADATA_KEY: f"{WEBUI_SYSTEM_COMMAND_TURN_PREFIX}model-switch",
|
||||||
|
},
|
||||||
|
event=TurnEndEvent(),
|
||||||
|
))
|
||||||
|
|
||||||
|
assert _sent_ws_payloads(mock_ws) == [
|
||||||
|
{
|
||||||
|
"event": "turn_end",
|
||||||
|
"chat_id": "chat-model",
|
||||||
|
"turn_id": f"{WEBUI_SYSTEM_COMMAND_TURN_PREFIX}model-switch",
|
||||||
|
"turn_phase": "complete",
|
||||||
|
"turn_seq": 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"event": "session_updated",
|
||||||
|
"chat_id": "chat-model",
|
||||||
|
"scope": "metadata",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
("active_owner", "event_owner", "expected_cleared"),
|
("active_owner", "event_owner", "expected_cleared"),
|
||||||
|
|||||||
@ -2937,6 +2937,17 @@ async def test_webui_thread_resigns_assistant_media_urls(
|
|||||||
assert media[0]["url"].startswith("/api/media/")
|
assert media[0]["url"].startswith("/api/media/")
|
||||||
assert media[0]["url"] != "/api/media/old-sig/old-payload"
|
assert media[0]["url"] != "/api/media/old-sig/old-payload"
|
||||||
|
|
||||||
|
repeated = await _http_get(
|
||||||
|
"http://127.0.0.1:29914/api/sessions/websocket:video-replay/webui-thread",
|
||||||
|
headers=auth,
|
||||||
|
)
|
||||||
|
repeated_assistant = next(
|
||||||
|
m for m in repeated.json()["messages"] if m["role"] == "assistant"
|
||||||
|
)
|
||||||
|
assert repeated_assistant["id"] == assistant["id"]
|
||||||
|
assert repeated_assistant["media"][0]["url"] == media[0]["url"]
|
||||||
|
assert len(list(websocket_media.iterdir())) == 1
|
||||||
|
|
||||||
fetched = await _http_get(f"http://127.0.0.1:29914{media[0]['url']}")
|
fetched = await _http_get(f"http://127.0.0.1:29914{media[0]['url']}")
|
||||||
assert fetched.status_code == 200
|
assert fetched.status_code == 200
|
||||||
assert fetched.content == b"video"
|
assert fetched.content == b"video"
|
||||||
|
|||||||
@ -146,16 +146,41 @@ def test_local_markdown_image_is_staged_and_rewritten(
|
|||||||
channel = _ch(bus, workspace_path=workspace, port=0)
|
channel = _ch(bus, workspace_path=workspace, port=0)
|
||||||
|
|
||||||
with patch("nanobot.webui.media_gateway.get_media_dir", side_effect=_fake_media_dir(media)):
|
with patch("nanobot.webui.media_gateway.get_media_dir", side_effect=_fake_media_dir(media)):
|
||||||
rewritten = channel.gateway.media.rewrite_local_markdown_images(
|
first = channel.gateway.media.rewrite_local_markdown_images(
|
||||||
|
"The result:\n"
|
||||||
|
)
|
||||||
|
second = channel.gateway.media.rewrite_local_markdown_images(
|
||||||
"The result:\n"
|
"The result:\n"
|
||||||
)
|
)
|
||||||
|
|
||||||
assert ".iterdir())
|
staged = list((media / "websocket").iterdir())
|
||||||
assert len(staged) == 1
|
assert len(staged) == 1
|
||||||
assert staged[0].read_bytes() == _PNG_BYTES
|
assert staged[0].read_bytes() == _PNG_BYTES
|
||||||
|
|
||||||
|
|
||||||
|
def test_modified_local_markdown_image_gets_a_new_immutable_url(
|
||||||
|
bus: MagicMock,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
workspace = tmp_path / "workspace"
|
||||||
|
workspace.mkdir()
|
||||||
|
source = workspace / "demo_arch.png"
|
||||||
|
source.write_bytes(_PNG_BYTES)
|
||||||
|
media = tmp_path / "media"
|
||||||
|
channel = _ch(bus, workspace_path=workspace, port=0)
|
||||||
|
markdown = ""
|
||||||
|
|
||||||
|
with patch("nanobot.webui.media_gateway.get_media_dir", side_effect=_fake_media_dir(media)):
|
||||||
|
first = channel.gateway.media.rewrite_local_markdown_images(markdown)
|
||||||
|
source.write_bytes(_PNG_BYTES + b"updated")
|
||||||
|
second = channel.gateway.media.rewrite_local_markdown_images(markdown)
|
||||||
|
|
||||||
|
assert second != first
|
||||||
|
assert len(list((media / "websocket").iterdir())) == 2
|
||||||
|
|
||||||
|
|
||||||
def test_local_markdown_video_is_staged_and_rewritten(
|
def test_local_markdown_video_is_staged_and_rewritten(
|
||||||
bus: MagicMock,
|
bus: MagicMock,
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import binascii
|
|||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
import mimetypes
|
import mimetypes
|
||||||
|
import os
|
||||||
import re
|
import re
|
||||||
import shutil
|
import shutil
|
||||||
import uuid
|
import uuid
|
||||||
@ -126,17 +127,33 @@ def sign_or_stage_media_path(
|
|||||||
signed = sign_media_path(path, secret=secret, media_dir=media_dir)
|
signed = sign_media_path(path, secret=secret, media_dir=media_dir)
|
||||||
if signed is not None:
|
if signed is not None:
|
||||||
return {"url": signed, "name": path.name}
|
return {"url": signed, "name": path.name}
|
||||||
|
staged_tmp: Path | None = None
|
||||||
try:
|
try:
|
||||||
if not path.is_file():
|
resolved = path.resolve(strict=True)
|
||||||
|
if not resolved.is_file():
|
||||||
return None
|
return None
|
||||||
|
source_stat = resolved.stat()
|
||||||
target_dir = media_dir("websocket")
|
target_dir = media_dir("websocket")
|
||||||
safe_name = safe_filename(path.name) or "attachment"
|
safe_name = safe_filename(path.name) or "attachment"
|
||||||
staged = target_dir / f"{uuid.uuid4().hex[:12]}-{safe_name}"
|
source_version = "\0".join((
|
||||||
shutil.copyfile(path, staged)
|
os.path.normcase(str(resolved)),
|
||||||
|
str(source_stat.st_size),
|
||||||
|
str(source_stat.st_mtime_ns),
|
||||||
|
str(source_stat.st_ctime_ns),
|
||||||
|
))
|
||||||
|
source_digest = hashlib.sha256(source_version.encode("utf-8")).hexdigest()[:20]
|
||||||
|
staged = target_dir / f"{source_digest}-{safe_name}"
|
||||||
|
if not staged.is_file() or staged.stat().st_size != source_stat.st_size:
|
||||||
|
staged_tmp = target_dir / f".{source_digest}-{uuid.uuid4().hex}.tmp"
|
||||||
|
shutil.copyfile(resolved, staged_tmp)
|
||||||
|
staged_tmp.replace(staged)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
if logger is not None:
|
if logger is not None:
|
||||||
logger.warning("failed to stage outbound media {}: {}", path, exc)
|
logger.warning("failed to stage outbound media {}: {}", path, exc)
|
||||||
return None
|
return None
|
||||||
|
finally:
|
||||||
|
if staged_tmp is not None:
|
||||||
|
staged_tmp.unlink(missing_ok=True)
|
||||||
signed = sign_media_path(staged, secret=secret, media_dir=media_dir)
|
signed = sign_media_path(staged, secret=secret, media_dir=media_dir)
|
||||||
if signed is None:
|
if signed is None:
|
||||||
return None
|
return None
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
"""Shared WebUI metadata keys."""
|
"""Shared WebUI metadata keys."""
|
||||||
|
|
||||||
WEBUI_TURN_METADATA_KEY = "webui_turn_id"
|
WEBUI_TURN_METADATA_KEY = "webui_turn_id"
|
||||||
|
WEBUI_SYSTEM_COMMAND_TURN_PREFIX = "webui-system:"
|
||||||
WEBSOCKET_TURN_OWNER_METADATA_KEY = "_websocket_turn_owner"
|
WEBSOCKET_TURN_OWNER_METADATA_KEY = "_websocket_turn_owner"
|
||||||
WEBUI_MESSAGE_SOURCE_METADATA_KEY = "_webui_message_source"
|
WEBUI_MESSAGE_SOURCE_METADATA_KEY = "_webui_message_source"
|
||||||
|
|||||||
@ -4,6 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import base64
|
import base64
|
||||||
import binascii
|
import binascii
|
||||||
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
@ -34,6 +35,7 @@ _TRANSCRIPT_SEGMENT_RE = re.compile(r"^\d{6}\.jsonl$")
|
|||||||
_DEFAULT_TRANSCRIPT_PAGE_LIMIT = 160
|
_DEFAULT_TRANSCRIPT_PAGE_LIMIT = 160
|
||||||
_MAX_TRANSCRIPT_PAGE_LIMIT = 1000
|
_MAX_TRANSCRIPT_PAGE_LIMIT = 1000
|
||||||
_WEBUI_TURN_ID_RE = re.compile(r"^[A-Za-z0-9._:-]{1,128}$")
|
_WEBUI_TURN_ID_RE = re.compile(r"^[A-Za-z0-9._:-]{1,128}$")
|
||||||
|
_WEBUI_REPLAY_IDENTITY_KEY = "_webui_replay_identity"
|
||||||
_MARKDOWN_LOCAL_IMAGE_RE = re.compile(
|
_MARKDOWN_LOCAL_IMAGE_RE = re.compile(
|
||||||
r"!\[([^\]]*)\]\((<[^>]+>|[^)\s]+)(\s+(?:\"[^\"]*\"|'[^']*'))?\)"
|
r"!\[([^\]]*)\]\((<[^>]+>|[^)\s]+)(\s+(?:\"[^\"]*\"|'[^']*'))?\)"
|
||||||
)
|
)
|
||||||
@ -194,6 +196,20 @@ def _flatten_turns(turns: list[list[dict[str, Any]]]) -> list[dict[str, Any]]:
|
|||||||
return [record for turn in turns for record in turn]
|
return [record for turn in turns for record in turn]
|
||||||
|
|
||||||
|
|
||||||
|
def _records_with_replay_identity(
|
||||||
|
records: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
turn_ordinal: int,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
**record,
|
||||||
|
_WEBUI_REPLAY_IDENTITY_KEY: f"turn:{turn_ordinal}:record:{record_index}",
|
||||||
|
}
|
||||||
|
for record_index, record in enumerate(records)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _write_records_to_path(path: Path, rows: list[dict[str, Any]]) -> None:
|
def _write_records_to_path(path: Path, rows: list[dict[str, Any]]) -> None:
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
tmp_path = path.with_suffix(path.suffix + ".tmp")
|
tmp_path = path.with_suffix(path.suffix + ".tmp")
|
||||||
@ -543,7 +559,14 @@ def _select_transcript_page(
|
|||||||
break
|
break
|
||||||
|
|
||||||
selected_chronological = list(reversed(selected))
|
selected_chronological = list(reversed(selected))
|
||||||
lines = [record for ref in selected_chronological for record in ref.records]
|
lines = [
|
||||||
|
record
|
||||||
|
for ref in selected_chronological
|
||||||
|
for record in _records_with_replay_identity(
|
||||||
|
ref.records,
|
||||||
|
turn_ordinal=ref.ordinal,
|
||||||
|
)
|
||||||
|
]
|
||||||
if not selected_chronological:
|
if not selected_chronological:
|
||||||
return [], {
|
return [], {
|
||||||
"before_cursor": None,
|
"before_cursor": None,
|
||||||
@ -1030,6 +1053,74 @@ def _split_transcript_turns(lines: list[dict[str, Any]]) -> list[list[dict[str,
|
|||||||
return turns
|
return turns
|
||||||
|
|
||||||
|
|
||||||
|
def _annotate_replay_identities(lines: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
return [
|
||||||
|
record
|
||||||
|
for turn_ordinal, turn in enumerate(_split_transcript_turns(lines))
|
||||||
|
for record in _records_with_replay_identity(
|
||||||
|
turn,
|
||||||
|
turn_ordinal=turn_ordinal,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _stable_record_digest(record: dict[str, Any]) -> str:
|
||||||
|
persisted = {
|
||||||
|
key: value
|
||||||
|
for key, value in record.items()
|
||||||
|
if key != _WEBUI_REPLAY_IDENTITY_KEY
|
||||||
|
}
|
||||||
|
raw = json.dumps(
|
||||||
|
persisted,
|
||||||
|
ensure_ascii=False,
|
||||||
|
separators=(",", ":"),
|
||||||
|
sort_keys=True,
|
||||||
|
default=str,
|
||||||
|
)
|
||||||
|
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_replay_identities(lines: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
"""Give backfilled/recovered rows a stable identity beside persisted rows."""
|
||||||
|
annotated: list[dict[str, Any]] = []
|
||||||
|
for fallback_turn_index, turn in enumerate(_split_transcript_turns(lines)):
|
||||||
|
anchor = next(
|
||||||
|
(
|
||||||
|
value
|
||||||
|
for record in turn
|
||||||
|
if isinstance(
|
||||||
|
value := record.get(_WEBUI_REPLAY_IDENTITY_KEY),
|
||||||
|
str,
|
||||||
|
)
|
||||||
|
and value
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if anchor and ":record:" in anchor:
|
||||||
|
turn_identity = anchor.rsplit(":record:", 1)[0]
|
||||||
|
else:
|
||||||
|
turn_digest = hashlib.sha256(
|
||||||
|
"\n".join(_stable_record_digest(record) for record in turn).encode("ascii")
|
||||||
|
).hexdigest()[:16]
|
||||||
|
turn_identity = f"legacy:{fallback_turn_index}:{turn_digest}"
|
||||||
|
synthetic_occurrences: dict[str, int] = {}
|
||||||
|
for record in turn:
|
||||||
|
identity = record.get(_WEBUI_REPLAY_IDENTITY_KEY)
|
||||||
|
if isinstance(identity, str) and identity:
|
||||||
|
annotated.append(record)
|
||||||
|
continue
|
||||||
|
digest = _stable_record_digest(record)
|
||||||
|
occurrence = synthetic_occurrences.get(digest, 0)
|
||||||
|
synthetic_occurrences[digest] = occurrence + 1
|
||||||
|
annotated.append({
|
||||||
|
**record,
|
||||||
|
_WEBUI_REPLAY_IDENTITY_KEY: (
|
||||||
|
f"{turn_identity}:synthetic:{digest}:{occurrence}"
|
||||||
|
),
|
||||||
|
})
|
||||||
|
return annotated
|
||||||
|
|
||||||
|
|
||||||
def _transcript_turn_signature(records: list[dict[str, Any]]) -> tuple[str, ...]:
|
def _transcript_turn_signature(records: list[dict[str, Any]]) -> tuple[str, ...]:
|
||||||
texts: list[str] = []
|
texts: list[str] = []
|
||||||
for message in replay_transcript_to_ui_messages(records):
|
for message in replay_transcript_to_ui_messages(records):
|
||||||
@ -1464,9 +1555,18 @@ def replay_transcript_to_ui_messages(
|
|||||||
_ts_base = _now_ms()
|
_ts_base = _now_ms()
|
||||||
closed_turn_ids: set[str] = set()
|
closed_turn_ids: set[str] = set()
|
||||||
replay_turn_aliases: dict[str, str] = {}
|
replay_turn_aliases: dict[str, str] = {}
|
||||||
|
generated_id_occurrences: dict[str, int] = {}
|
||||||
|
|
||||||
def _new_id(prefix: str, idx: int) -> str:
|
def _new_id(prefix: str, idx: int) -> str:
|
||||||
return f"{prefix}-{idx}-{uuid.uuid4().hex[:8]}"
|
record = lines[idx] if 0 <= idx < len(lines) else {}
|
||||||
|
identity = record.get(_WEBUI_REPLAY_IDENTITY_KEY)
|
||||||
|
if not isinstance(identity, str) or not identity:
|
||||||
|
identity = f"direct:{idx}:{_stable_record_digest(record)}"
|
||||||
|
digest = hashlib.sha256(f"{prefix}\0{identity}".encode("utf-8")).hexdigest()[:16]
|
||||||
|
base = f"{prefix}-{digest}"
|
||||||
|
occurrence = generated_id_occurrences.get(base, 0)
|
||||||
|
generated_id_occurrences[base] = occurrence + 1
|
||||||
|
return base if occurrence == 0 else f"{base}-{occurrence}"
|
||||||
|
|
||||||
def _created_at_ms(rec: dict[str, Any], idx: int) -> int:
|
def _created_at_ms(rec: dict[str, Any], idx: int) -> int:
|
||||||
created_at_ms = _valid_created_at_ms(rec.get("created_at_ms"))
|
created_at_ms = _valid_created_at_ms(rec.get("created_at_ms"))
|
||||||
@ -2255,7 +2355,7 @@ def build_webui_thread_response(
|
|||||||
if paginated:
|
if paginated:
|
||||||
lines, page = _select_transcript_page(session_key, limit=limit, before=before)
|
lines, page = _select_transcript_page(session_key, limit=limit, before=before)
|
||||||
else:
|
else:
|
||||||
lines = read_transcript_lines(session_key)
|
lines = _annotate_replay_identities(read_transcript_lines(session_key))
|
||||||
if not lines and active_turn_started_at is None:
|
if not lines and active_turn_started_at is None:
|
||||||
return None
|
return None
|
||||||
lines = inject_missing_user_events_from_session(session_key, lines, session_messages)
|
lines = inject_missing_user_events_from_session(session_key, lines, session_messages)
|
||||||
@ -2264,6 +2364,7 @@ def build_webui_thread_response(
|
|||||||
session_messages,
|
session_messages,
|
||||||
session_key=session_key,
|
session_key=session_key,
|
||||||
)
|
)
|
||||||
|
lines = _ensure_replay_identities(lines)
|
||||||
fork_boundary = fork_boundary_message_count(lines)
|
fork_boundary = fork_boundary_message_count(lines)
|
||||||
msgs = replay_transcript_to_ui_messages(
|
msgs = replay_transcript_to_ui_messages(
|
||||||
lines,
|
lines,
|
||||||
|
|||||||
@ -107,6 +107,20 @@ def test_segmented_transcript_paginates_latest_and_older_without_overlap(
|
|||||||
assert older["page"]["user_message_offset"] == 2
|
assert older["page"]["user_message_offset"] == 2
|
||||||
assert _message_contents(older) == _numbered_turn_texts(3, 4)
|
assert _message_contents(older) == _numbered_turn_texts(3, 4)
|
||||||
|
|
||||||
|
latest_again = build_webui_thread_response(key, limit=4, direction="latest")
|
||||||
|
full = build_webui_thread_response(key)
|
||||||
|
assert latest_again is not None
|
||||||
|
assert full is not None
|
||||||
|
assert [message["id"] for message in latest_again["messages"]] == [
|
||||||
|
message["id"] for message in latest["messages"]
|
||||||
|
]
|
||||||
|
full_ids_by_content = {
|
||||||
|
message["content"]: message["id"] for message in full["messages"]
|
||||||
|
}
|
||||||
|
assert [full_ids_by_content[message["content"]] for message in latest["messages"]] == [
|
||||||
|
message["id"] for message in latest["messages"]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_page_cursor_survives_active_rotation_after_latest_page(
|
def test_page_cursor_survives_active_rotation_after_latest_page(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
@ -279,6 +293,21 @@ def test_write_session_messages_as_transcript_builds_canonical_prefix(
|
|||||||
assert [m["content"] for m in msgs] == ["round1", "answer1"]
|
assert [m["content"] for m in msgs] == ["round1", "answer1"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_direct_transcript_replay_generates_stable_message_ids() -> None:
|
||||||
|
lines = [
|
||||||
|
{"event": "user", "chat_id": "stable", "text": "question"},
|
||||||
|
{"event": "message", "chat_id": "stable", "text": "answer"},
|
||||||
|
{"event": "turn_end", "chat_id": "stable"},
|
||||||
|
]
|
||||||
|
|
||||||
|
first = replay_transcript_to_ui_messages(lines)
|
||||||
|
second = replay_transcript_to_ui_messages(lines)
|
||||||
|
|
||||||
|
assert [message["id"] for message in second] == [
|
||||||
|
message["id"] for message in first
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_replay_delta_and_turn_end(tmp_path, monkeypatch) -> None:
|
def test_replay_delta_and_turn_end(tmp_path, monkeypatch) -> None:
|
||||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||||
key = "websocket:t2"
|
key = "websocket:t2"
|
||||||
|
|||||||
@ -936,7 +936,7 @@ function Shell({
|
|||||||
onNativeEngineRestart: () => Promise<string>;
|
onNativeEngineRestart: () => Promise<string>;
|
||||||
}) {
|
}) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { client, token } = useClient();
|
const { client, getToken } = useClient();
|
||||||
const { theme, toggle } = useTheme();
|
const { theme, toggle } = useTheme();
|
||||||
const {
|
const {
|
||||||
sessions,
|
sessions,
|
||||||
@ -981,13 +981,14 @@ function Shell({
|
|||||||
const [pairingRequests, setPairingRequests] = useState<PairingRequestInfo[]>([]);
|
const [pairingRequests, setPairingRequests] = useState<PairingRequestInfo[]>([]);
|
||||||
const [pairingBusyCode, setPairingBusyCode] = useState<string | null>(null);
|
const [pairingBusyCode, setPairingBusyCode] = useState<string | null>(null);
|
||||||
const [pairingError, setPairingError] = useState<string | null>(null);
|
const [pairingError, setPairingError] = useState<string | null>(null);
|
||||||
|
const pairingRefreshRef = useRef<Promise<number> | null>(null);
|
||||||
const [snoozedPairingCodes, setSnoozedPairingCodes] = useState<Map<string, number>>(
|
const [snoozedPairingCodes, setSnoozedPairingCodes] = useState<Map<string, number>>(
|
||||||
() => new Map(),
|
() => new Map(),
|
||||||
);
|
);
|
||||||
const [runningChatIds, setRunningChatIds] = useState<Set<string>>(() => new Set());
|
const [runningChatIds, setRunningChatIds] = useState<Set<string>>(() => new Set());
|
||||||
const [updatedChatIds, setUpdatedChatIds] = useState<Set<string>>(readSessionUpdateChatIds);
|
const [updatedChatIds, setUpdatedChatIds] = useState<Set<string>>(readSessionUpdateChatIds);
|
||||||
const [workspaces, setWorkspaces] = useState<WorkspacesPayload | null>(null);
|
const [workspaces, setWorkspaces] = useState<WorkspacesPayload | null>(null);
|
||||||
const skills = useSkills(token);
|
const skills = useSkills(getToken);
|
||||||
const pageVisible = usePageVisibility();
|
const pageVisible = usePageVisibility();
|
||||||
const [settingsSnapshot, setSettingsSnapshot] = useState<SettingsPayload | null>(null);
|
const [settingsSnapshot, setSettingsSnapshot] = useState<SettingsPayload | null>(null);
|
||||||
const [workspaceError, setWorkspaceError] = useState<string | null>(null);
|
const [workspaceError, setWorkspaceError] = useState<string | null>(null);
|
||||||
@ -1030,7 +1031,7 @@ function Shell({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
fetchSettings(token)
|
fetchSettings(getToken())
|
||||||
.then((payload) => {
|
.then((payload) => {
|
||||||
if (!cancelled) setSettingsSnapshot(payload);
|
if (!cancelled) setSettingsSnapshot(payload);
|
||||||
})
|
})
|
||||||
@ -1040,7 +1041,7 @@ function Shell({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [token]);
|
}, [getToken]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
@ -1057,29 +1058,39 @@ function Shell({
|
|||||||
writeSessionUpdateChatIds(updatedChatIds);
|
writeSessionUpdateChatIds(updatedChatIds);
|
||||||
}, [updatedChatIds]);
|
}, [updatedChatIds]);
|
||||||
|
|
||||||
const refreshPairingRequests = useCallback(async (): Promise<number> => {
|
const refreshPairingRequests = useCallback((): Promise<number> => {
|
||||||
try {
|
if (pairingRefreshRef.current) return pairingRefreshRef.current;
|
||||||
const payload = await fetchPairingRequests(token);
|
|
||||||
const requests = Array.isArray(payload.requests) ? payload.requests : [];
|
const request = (async () => {
|
||||||
setPairingRequests(requests);
|
try {
|
||||||
setSnoozedPairingCodes((current) => {
|
const payload = await fetchPairingRequests(getToken());
|
||||||
if (current.size === 0) return current;
|
const requests = Array.isArray(payload.requests) ? payload.requests : [];
|
||||||
const activeCodes = new Set(requests.map((request) => request.code));
|
setPairingRequests(requests);
|
||||||
const now = Date.now();
|
setSnoozedPairingCodes((current) => {
|
||||||
const next = new Map(
|
if (current.size === 0) return current;
|
||||||
Array.from(current).filter(
|
const activeCodes = new Set(requests.map((request) => request.code));
|
||||||
([code, snoozedUntil]) => activeCodes.has(code) && snoozedUntil > now,
|
const now = Date.now();
|
||||||
),
|
const next = new Map(
|
||||||
);
|
Array.from(current).filter(
|
||||||
return next.size === current.size ? current : next;
|
([code, snoozedUntil]) => activeCodes.has(code) && snoozedUntil > now,
|
||||||
});
|
),
|
||||||
return requests.length;
|
);
|
||||||
} catch {
|
return next.size === current.size ? current : next;
|
||||||
// Pairing is an opportunistic WebUI affordance. The slash command path
|
});
|
||||||
// remains available if this polling request fails.
|
return requests.length;
|
||||||
return 0;
|
} catch {
|
||||||
}
|
// Pairing is an opportunistic WebUI affordance. The slash command path
|
||||||
}, [token]);
|
// remains available if this polling request fails.
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
const clearRequest = () => {
|
||||||
|
if (pairingRefreshRef.current === request) pairingRefreshRef.current = null;
|
||||||
|
};
|
||||||
|
pairingRefreshRef.current = request;
|
||||||
|
void request.then(clearRequest, clearRequest);
|
||||||
|
return request;
|
||||||
|
}, [getToken]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!pageVisible) return undefined;
|
if (!pageVisible) return undefined;
|
||||||
@ -1137,12 +1148,12 @@ function Shell({
|
|||||||
|
|
||||||
const refreshWorkspaces = useCallback(async () => {
|
const refreshWorkspaces = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const payload = await fetchWorkspaces(token);
|
const payload = await fetchWorkspaces(getToken());
|
||||||
setWorkspaces(payload);
|
setWorkspaces(payload);
|
||||||
} catch {
|
} catch {
|
||||||
setWorkspaces(null);
|
setWorkspaces(null);
|
||||||
}
|
}
|
||||||
}, [token]);
|
}, [getToken]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void refreshWorkspaces();
|
void refreshWorkspaces();
|
||||||
@ -1824,7 +1835,7 @@ function Shell({
|
|||||||
setPairingBusyCode(code);
|
setPairingBusyCode(code);
|
||||||
setPairingError(null);
|
setPairingError(null);
|
||||||
try {
|
try {
|
||||||
const payload = await runPairingAction(token, action, code);
|
const payload = await runPairingAction(getToken(), action, code);
|
||||||
setPairingRequests(Array.isArray(payload.requests) ? payload.requests : []);
|
setPairingRequests(Array.isArray(payload.requests) ? payload.requests : []);
|
||||||
setSnoozedPairingCodes((current) => {
|
setSnoozedPairingCodes((current) => {
|
||||||
if (!current.has(code)) return current;
|
if (!current.has(code)) return current;
|
||||||
@ -1839,7 +1850,7 @@ function Shell({
|
|||||||
setPairingBusyCode(null);
|
setPairingBusyCode(null);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[refreshPairingRequests, token],
|
[getToken, refreshPairingRequests],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onDismissPairingRequest = useCallback((code: string) => {
|
const onDismissPairingRequest = useCallback((code: string) => {
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
|
import type { CSSProperties, PointerEvent as ReactPointerEvent } from "react";
|
||||||
import { AlertCircle, ChevronRight, Loader2, X } from "lucide-react";
|
import { AlertCircle, ChevronRight, Loader2, X } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
@ -21,7 +21,7 @@ interface FilePreviewPanelProps {
|
|||||||
|
|
||||||
type PreviewState =
|
type PreviewState =
|
||||||
| { status: "loading" }
|
| { status: "loading" }
|
||||||
| { status: "error"; message: string }
|
| { status: "error"; error: unknown }
|
||||||
| { status: "ready"; payload: FilePreviewPayload };
|
| { status: "ready"; payload: FilePreviewPayload };
|
||||||
|
|
||||||
export function FilePreviewPanel({
|
export function FilePreviewPanel({
|
||||||
@ -36,6 +36,8 @@ export function FilePreviewPanel({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [state, setState] = useState<PreviewState>({ status: "loading" });
|
const [state, setState] = useState<PreviewState>({ status: "loading" });
|
||||||
const [entered, setEntered] = useState(false);
|
const [entered, setEntered] = useState(false);
|
||||||
|
const tokenRef = useRef(token);
|
||||||
|
tokenRef.current = token;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const frame = window.requestAnimationFrame(() => setEntered(true));
|
const frame = window.requestAnimationFrame(() => setEntered(true));
|
||||||
@ -45,25 +47,17 @@ export function FilePreviewPanel({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setState({ status: "loading" });
|
setState({ status: "loading" });
|
||||||
fetchFilePreview(token, sessionKey, path)
|
fetchFilePreview(tokenRef.current, sessionKey, path)
|
||||||
.then((payload) => {
|
.then((payload) => {
|
||||||
if (!cancelled) setState({ status: "ready", payload });
|
if (!cancelled) setState({ status: "ready", payload });
|
||||||
})
|
})
|
||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
if (cancelled) return;
|
if (!cancelled) setState({ status: "error", error });
|
||||||
const message = error instanceof ApiError
|
|
||||||
? (error.status === 404 && /API route not found/i.test(error.message)
|
|
||||||
? t("filePreview.routeMissing", {
|
|
||||||
defaultValue: "File preview needs the latest gateway. Restart nanobot gateway and try again.",
|
|
||||||
})
|
|
||||||
: error.message)
|
|
||||||
: t("filePreview.failed", { defaultValue: "Could not preview this file." });
|
|
||||||
setState({ status: "error", message });
|
|
||||||
});
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [path, sessionKey, t, token]);
|
}, [path, sessionKey]);
|
||||||
|
|
||||||
const displayPath = state.status === "ready" ? state.payload.display_path : path;
|
const displayPath = state.status === "ready" ? state.payload.display_path : path;
|
||||||
const previewPath = state.status === "ready" ? state.payload.path : displayPath;
|
const previewPath = state.status === "ready" ? state.payload.path : displayPath;
|
||||||
@ -92,6 +86,15 @@ export function FilePreviewPanel({
|
|||||||
...directoryParts,
|
...directoryParts,
|
||||||
fileName,
|
fileName,
|
||||||
].join("/")}`;
|
].join("/")}`;
|
||||||
|
const errorMessage = state.status === "error"
|
||||||
|
? (state.error instanceof ApiError
|
||||||
|
? (state.error.status === 404 && /API route not found/i.test(state.error.message)
|
||||||
|
? t("filePreview.routeMissing", {
|
||||||
|
defaultValue: "File preview needs the latest gateway. Restart nanobot gateway and try again.",
|
||||||
|
})
|
||||||
|
: state.error.message)
|
||||||
|
: t("filePreview.failed", { defaultValue: "Could not preview this file." }))
|
||||||
|
: null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
@ -222,7 +225,7 @@ export function FilePreviewPanel({
|
|||||||
className="mx-auto mb-3 h-5 w-5 text-muted-foreground/70"
|
className="mx-auto mb-3 h-5 w-5 text-muted-foreground/70"
|
||||||
aria-hidden
|
aria-hidden
|
||||||
/>
|
/>
|
||||||
<p>{state.message}</p>
|
<p>{errorMessage}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -633,7 +633,7 @@ export function SettingsView({
|
|||||||
hostChromeInset = false,
|
hostChromeInset = false,
|
||||||
}: SettingsViewProps) {
|
}: SettingsViewProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { token } = useClient();
|
const { getToken, token } = useClient();
|
||||||
const pageVisible = usePageVisibility();
|
const pageVisible = usePageVisibility();
|
||||||
const remoteBrowserAccess =
|
const remoteBrowserAccess =
|
||||||
typeof window !== "undefined" && !isLoopbackHost(window.location.hostname);
|
typeof window !== "undefined" && !isLoopbackHost(window.location.hostname);
|
||||||
@ -779,7 +779,7 @@ export function SettingsView({
|
|||||||
const poll = async () => {
|
const poll = async () => {
|
||||||
try {
|
try {
|
||||||
const payload = await completeProviderOAuth(
|
const payload = await completeProviderOAuth(
|
||||||
token,
|
getToken(),
|
||||||
xaiOAuthFlow.provider,
|
xaiOAuthFlow.provider,
|
||||||
xaiOAuthFlow.flow_id,
|
xaiOAuthFlow.flow_id,
|
||||||
);
|
);
|
||||||
@ -803,7 +803,7 @@ export function SettingsView({
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
if (timer !== null) window.clearTimeout(timer);
|
if (timer !== null) window.clearTimeout(timer);
|
||||||
};
|
};
|
||||||
}, [applyPayload, closeXaiOAuthFlow, token, xaiOAuthFlow]);
|
}, [applyPayload, closeXaiOAuthFlow, getToken, xaiOAuthFlow]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!initialSettings || settings !== null) return;
|
if (!initialSettings || settings !== null) return;
|
||||||
@ -815,7 +815,7 @@ export function SettingsView({
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const showLoading = settings === null;
|
const showLoading = settings === null;
|
||||||
if (showLoading) setLoading(true);
|
if (showLoading) setLoading(true);
|
||||||
fetchSettings(token)
|
fetchSettings(getToken())
|
||||||
.then((payload) => {
|
.then((payload) => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
applyPayload(payload);
|
applyPayload(payload);
|
||||||
@ -831,30 +831,37 @@ export function SettingsView({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [applyPayload, token]);
|
}, [applyPayload, getToken]);
|
||||||
|
|
||||||
const hasSettings = settings !== null;
|
const hasSettings = settings !== null;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeSection !== "overview" || !hasSettings || !pageVisible) return;
|
if (activeSection !== "overview" || !hasSettings || !pageVisible) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const refresh = () => {
|
let refreshing = false;
|
||||||
fetchSettingsUsage(token)
|
const refresh = async () => {
|
||||||
.then((usage) => {
|
if (refreshing) return;
|
||||||
if (cancelled) return;
|
refreshing = true;
|
||||||
|
try {
|
||||||
|
const usage = await fetchSettingsUsage(getToken());
|
||||||
|
if (!cancelled) {
|
||||||
setSettings((current) => (current ? { ...current, usage } : current));
|
setSettings((current) => (current ? { ...current, usage } : current));
|
||||||
})
|
}
|
||||||
.catch(() => {});
|
} catch {
|
||||||
|
// Usage is best-effort telemetry; the settings snapshot remains usable.
|
||||||
|
} finally {
|
||||||
|
refreshing = false;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
void refresh();
|
void refresh();
|
||||||
const interval = window.setInterval(refresh, 5000);
|
const interval = window.setInterval(() => void refresh(), 5000);
|
||||||
const onFocus = () => refresh();
|
const onFocus = () => void refresh();
|
||||||
window.addEventListener("focus", onFocus);
|
window.addEventListener("focus", onFocus);
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
window.clearInterval(interval);
|
window.clearInterval(interval);
|
||||||
window.removeEventListener("focus", onFocus);
|
window.removeEventListener("focus", onFocus);
|
||||||
};
|
};
|
||||||
}, [activeSection, hasSettings, pageVisible, token]);
|
}, [activeSection, getToken, hasSettings, pageVisible]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeSection !== "apps") return;
|
if (activeSection !== "apps") return;
|
||||||
@ -863,7 +870,7 @@ export function SettingsView({
|
|||||||
let retryCount = 0;
|
let retryCount = 0;
|
||||||
const loadCliApps = (showLoading: boolean) => {
|
const loadCliApps = (showLoading: boolean) => {
|
||||||
if (showLoading) setCliAppsLoading(true);
|
if (showLoading) setCliAppsLoading(true);
|
||||||
fetchCliApps(token)
|
fetchCliApps(getToken())
|
||||||
.then((payload) => {
|
.then((payload) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
if (payload.catalog_refresh_pending && retryCount < CLI_APPS_REFRESH_MAX_RETRIES) {
|
if (payload.catalog_refresh_pending && retryCount < CLI_APPS_REFRESH_MAX_RETRIES) {
|
||||||
@ -889,15 +896,23 @@ export function SettingsView({
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
if (retry !== null) window.clearTimeout(retry);
|
if (retry !== null) window.clearTimeout(retry);
|
||||||
};
|
};
|
||||||
}, [activeSection, token]);
|
}, [activeSection, getToken]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!["channels", "models", "browser", "runtime"].includes(activeSection)) return;
|
if (
|
||||||
|
!pageVisible
|
||||||
|
|| !["channels", "models", "browser", "runtime"].includes(activeSection)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
const refresh = async (showLoading = false) => {
|
let refreshing = false;
|
||||||
|
const refresh = async (showLoading = false): Promise<void> => {
|
||||||
|
if (refreshing) return;
|
||||||
|
refreshing = true;
|
||||||
if (showLoading) setNanobotFeaturesLoading(true);
|
if (showLoading) setNanobotFeaturesLoading(true);
|
||||||
try {
|
try {
|
||||||
const payload = await fetchNanobotFeatures(token);
|
const payload = await fetchNanobotFeatures(getToken());
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setNanobotFeatures(payload);
|
setNanobotFeatures(payload);
|
||||||
setNanobotFeaturesError(null);
|
setNanobotFeaturesError(null);
|
||||||
@ -906,6 +921,7 @@ export function SettingsView({
|
|||||||
const message = (err as Error).message;
|
const message = (err as Error).message;
|
||||||
if (!cancelled && message !== "HTTP 404") setNanobotFeaturesError(message);
|
if (!cancelled && message !== "HTTP 404") setNanobotFeaturesError(message);
|
||||||
} finally {
|
} finally {
|
||||||
|
refreshing = false;
|
||||||
if (!cancelled && showLoading) setNanobotFeaturesLoading(false);
|
if (!cancelled && showLoading) setNanobotFeaturesLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -926,13 +942,13 @@ export function SettingsView({
|
|||||||
window.removeEventListener("focus", refreshOnFocus);
|
window.removeEventListener("focus", refreshOnFocus);
|
||||||
document.removeEventListener("visibilitychange", refreshOnFocus);
|
document.removeEventListener("visibilitychange", refreshOnFocus);
|
||||||
};
|
};
|
||||||
}, [activeSection, token]);
|
}, [activeSection, getToken, pageVisible]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeSection !== "runtime") return;
|
if (activeSection !== "runtime") return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setApiServiceLoading(true);
|
setApiServiceLoading(true);
|
||||||
fetchApiService(token)
|
fetchApiService(getToken())
|
||||||
.then((payload) => {
|
.then((payload) => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setApiService(payload);
|
setApiService(payload);
|
||||||
@ -948,13 +964,13 @@ export function SettingsView({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [activeSection, token]);
|
}, [activeSection, getToken]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeSection !== "apps") return;
|
if (activeSection !== "apps") return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setMcpPresetsLoading(true);
|
setMcpPresetsLoading(true);
|
||||||
fetchMcpPresets(token)
|
fetchMcpPresets(getToken())
|
||||||
.then((payload) => {
|
.then((payload) => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setMcpPresets(payload);
|
setMcpPresets(payload);
|
||||||
@ -970,13 +986,13 @@ export function SettingsView({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [activeSection, token]);
|
}, [activeSection, getToken]);
|
||||||
|
|
||||||
const refreshAutomations = useCallback(
|
const refreshAutomations = useCallback(
|
||||||
async (showLoading = false) => {
|
async (showLoading = false) => {
|
||||||
if (showLoading) setAutomationsLoading(true);
|
if (showLoading) setAutomationsLoading(true);
|
||||||
try {
|
try {
|
||||||
const payload = await fetchAutomations(token);
|
const payload = await fetchAutomations(getToken());
|
||||||
setAutomations(payload);
|
setAutomations(payload);
|
||||||
setAutomationsError(null);
|
setAutomationsError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -985,23 +1001,26 @@ export function SettingsView({
|
|||||||
if (showLoading) setAutomationsLoading(false);
|
if (showLoading) setAutomationsLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[token],
|
[getToken],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeSection !== "automations" || !pageVisible) return;
|
if (activeSection !== "automations" || !pageVisible) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
let refreshing = false;
|
||||||
const refresh = async (showLoading = false) => {
|
const refresh = async (showLoading = false) => {
|
||||||
if (cancelled) return;
|
if (cancelled || refreshing) return;
|
||||||
|
refreshing = true;
|
||||||
if (showLoading) setAutomationsLoading(true);
|
if (showLoading) setAutomationsLoading(true);
|
||||||
try {
|
try {
|
||||||
const payload = await fetchAutomations(token);
|
const payload = await fetchAutomations(getToken());
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setAutomations(payload);
|
setAutomations(payload);
|
||||||
setAutomationsError(null);
|
setAutomationsError(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (!cancelled) setAutomationsError((err as Error).message);
|
if (!cancelled) setAutomationsError((err as Error).message);
|
||||||
} finally {
|
} finally {
|
||||||
|
refreshing = false;
|
||||||
if (!cancelled && showLoading) setAutomationsLoading(false);
|
if (!cancelled && showLoading) setAutomationsLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -1014,7 +1033,7 @@ export function SettingsView({
|
|||||||
window.clearInterval(interval);
|
window.clearInterval(interval);
|
||||||
window.removeEventListener("focus", refreshOnFocus);
|
window.removeEventListener("focus", refreshOnFocus);
|
||||||
};
|
};
|
||||||
}, [activeSection, pageVisible, token]);
|
}, [activeSection, getToken, pageVisible]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
writeLocalPreferences(localPrefs);
|
writeLocalPreferences(localPrefs);
|
||||||
@ -8899,6 +8918,8 @@ function ModelIdPicker({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
||||||
|
const tokenRef = useRef(token);
|
||||||
|
tokenRef.current = token;
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [payload, setPayload] = useState<ProviderModelsPayload | null>(null);
|
const [payload, setPayload] = useState<ProviderModelsPayload | null>(null);
|
||||||
@ -8967,7 +8988,7 @@ function ModelIdPicker({
|
|||||||
setPayload(null);
|
setPayload(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
fetchProviderModels(token, effectiveProvider)
|
fetchProviderModels(tokenRef.current, effectiveProvider)
|
||||||
.then((nextPayload) => {
|
.then((nextPayload) => {
|
||||||
if (!cancelled) setPayload(nextPayload);
|
if (!cancelled) setPayload(nextPayload);
|
||||||
})
|
})
|
||||||
@ -8980,7 +9001,7 @@ function ModelIdPicker({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [effectiveProvider, open, shouldFetchModels, token]);
|
}, [effectiveProvider, open, shouldFetchModels]);
|
||||||
|
|
||||||
const selectModel = (model: string) => {
|
const selectModel = (model: string) => {
|
||||||
onChange(model);
|
onChange(model);
|
||||||
|
|||||||
@ -302,7 +302,7 @@ function SkillDetailSheet({
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
onOpenChange: (open: boolean) => void;
|
onOpenChange: (open: boolean) => void;
|
||||||
}) {
|
}) {
|
||||||
const { token } = useClient();
|
const { getToken } = useClient();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [detail, setDetail] = useState<SkillDetail | null>(null);
|
const [detail, setDetail] = useState<SkillDetail | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@ -322,7 +322,7 @@ function SkillDetailSheet({
|
|||||||
setActionError("");
|
setActionError("");
|
||||||
setDeleteOpen(false);
|
setDeleteOpen(false);
|
||||||
setDescriptionExpanded(false);
|
setDescriptionExpanded(false);
|
||||||
fetchSkillDetail(token, skill.name)
|
fetchSkillDetail(getToken(), skill.name)
|
||||||
.then((payload) => {
|
.then((payload) => {
|
||||||
if (!cancelled) setDetail(payload);
|
if (!cancelled) setDetail(payload);
|
||||||
})
|
})
|
||||||
@ -335,7 +335,7 @@ function SkillDetailSheet({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [open, refreshKey, skill, token]);
|
}, [getToken, open, refreshKey, skill]);
|
||||||
|
|
||||||
if (!skill) return null;
|
if (!skill) return null;
|
||||||
|
|
||||||
@ -354,7 +354,7 @@ function SkillDetailSheet({
|
|||||||
setActionBusy(true);
|
setActionBusy(true);
|
||||||
setActionError("");
|
setActionError("");
|
||||||
try {
|
try {
|
||||||
const payload = await updateSkillEnabled(token, activeSkill.name, !enabled);
|
const payload = await updateSkillEnabled(getToken(), activeSkill.name, !enabled);
|
||||||
notifySkillsChanged(payload);
|
notifySkillsChanged(payload);
|
||||||
const updated = payload.skills.find((item) => item.name === activeSkill.name);
|
const updated = payload.skills.find((item) => item.name === activeSkill.name);
|
||||||
if (updated) {
|
if (updated) {
|
||||||
@ -378,7 +378,7 @@ function SkillDetailSheet({
|
|||||||
setActionBusy(true);
|
setActionBusy(true);
|
||||||
setActionError("");
|
setActionError("");
|
||||||
try {
|
try {
|
||||||
const payload = await deleteSkill(token, activeSkill.name);
|
const payload = await deleteSkill(getToken(), activeSkill.name);
|
||||||
notifySkillsChanged(payload);
|
notifySkillsChanged(payload);
|
||||||
onOpenChange(false);
|
onOpenChange(false);
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
|
|||||||
@ -45,7 +45,7 @@ export function SkillsMarketplace({
|
|||||||
installing: string;
|
installing: string;
|
||||||
onInstallingChange: (skillId: string) => void;
|
onInstallingChange: (skillId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const { token } = useClient();
|
const { getToken } = useClient();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [results, setResults] = useState<MarketplaceSkillSummary[]>([]);
|
const [results, setResults] = useState<MarketplaceSkillSummary[]>([]);
|
||||||
@ -78,7 +78,7 @@ export function SkillsMarketplace({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
setTrendingLoading(true);
|
setTrendingLoading(true);
|
||||||
fetchTrendingMarketplaceSkills(token)
|
fetchTrendingMarketplaceSkills(getToken())
|
||||||
.then((payload) => {
|
.then((payload) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setTrending(payload.skills);
|
setTrending(payload.skills);
|
||||||
@ -92,7 +92,7 @@ export function SkillsMarketplace({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [token]);
|
}, [getToken]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const skills = query.trim().length < 2 ? trending : results;
|
const skills = query.trim().length < 2 ? trending : results;
|
||||||
@ -102,7 +102,7 @@ export function SkillsMarketplace({
|
|||||||
if (!unresolved.length) return;
|
if (!unresolved.length) return;
|
||||||
|
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
fetchMarketplaceSkillTrends(token, unresolved.map((skill) => skill.id))
|
fetchMarketplaceSkillTrends(getToken(), unresolved.map((skill) => skill.id))
|
||||||
.then((payload) => {
|
.then((payload) => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setTrends((current) => ({ ...current, ...payload.trends }));
|
setTrends((current) => ({ ...current, ...payload.trends }));
|
||||||
@ -112,7 +112,7 @@ export function SkillsMarketplace({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [query, results, token, trending, trends]);
|
}, [getToken, query, results, trending, trends]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const normalized = query.trim();
|
const normalized = query.trim();
|
||||||
@ -127,7 +127,7 @@ export function SkillsMarketplace({
|
|||||||
const timer = window.setTimeout(() => {
|
const timer = window.setTimeout(() => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError("");
|
setError("");
|
||||||
searchMarketplaceSkills(token, normalized)
|
searchMarketplaceSkills(getToken(), normalized)
|
||||||
.then((payload) => {
|
.then((payload) => {
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setResults(payload.skills);
|
setResults(payload.skills);
|
||||||
@ -152,7 +152,7 @@ export function SkillsMarketplace({
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
window.clearTimeout(timer);
|
window.clearTimeout(timer);
|
||||||
};
|
};
|
||||||
}, [query, t, token]);
|
}, [getToken, query, t]);
|
||||||
|
|
||||||
const install = async (skill: MarketplaceSkillSummary) => {
|
const install = async (skill: MarketplaceSkillSummary) => {
|
||||||
setSelected(null);
|
setSelected(null);
|
||||||
@ -160,7 +160,7 @@ export function SkillsMarketplace({
|
|||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
const payload = await installMarketplaceSkill(
|
const payload = await installMarketplaceSkill(
|
||||||
token,
|
getToken(),
|
||||||
skill.provider,
|
skill.provider,
|
||||||
skill.source,
|
skill.source,
|
||||||
skill.skill_id,
|
skill.skill_id,
|
||||||
|
|||||||
@ -62,6 +62,8 @@ export function ChannelQrConnectFlow({
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [handledRequestId, setHandledRequestId] = useState(0);
|
const [handledRequestId, setHandledRequestId] = useState(0);
|
||||||
const pollInFlight = useRef(false);
|
const pollInFlight = useRef(false);
|
||||||
|
const tokenRef = useRef(token);
|
||||||
|
tokenRef.current = token;
|
||||||
const startDomain = startOptions.domain;
|
const startDomain = startOptions.domain;
|
||||||
const startInstanceId = startOptions.instanceId;
|
const startInstanceId = startOptions.instanceId;
|
||||||
const startMode = startOptions.mode;
|
const startMode = startOptions.mode;
|
||||||
@ -100,7 +102,11 @@ export function ChannelQrConnectFlow({
|
|||||||
if (pollInFlight.current) return;
|
if (pollInFlight.current) return;
|
||||||
pollInFlight.current = true;
|
pollInFlight.current = true;
|
||||||
try {
|
try {
|
||||||
const payload = await pollChannelConnect(token, channelName, connect.session_id);
|
const payload = await pollChannelConnect(
|
||||||
|
tokenRef.current,
|
||||||
|
channelName,
|
||||||
|
connect.session_id,
|
||||||
|
);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setConnect((current) => ({
|
setConnect((current) => ({
|
||||||
...(current ?? payload),
|
...(current ?? payload),
|
||||||
@ -129,13 +135,20 @@ export function ChannelQrConnectFlow({
|
|||||||
window.clearTimeout(initial);
|
window.clearTimeout(initial);
|
||||||
window.clearInterval(interval);
|
window.clearInterval(interval);
|
||||||
};
|
};
|
||||||
}, [channelName, connect?.interval_ms, connect?.session_id, connect?.status, onFeaturesUpdate, pageVisible, token]);
|
}, [
|
||||||
|
channelName,
|
||||||
|
connect?.interval_ms,
|
||||||
|
connect?.session_id,
|
||||||
|
connect?.status,
|
||||||
|
onFeaturesUpdate,
|
||||||
|
pageVisible,
|
||||||
|
]);
|
||||||
|
|
||||||
const start = useCallback(async (force = false) => {
|
const start = useCallback(async (force = false) => {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const payload = await startChannelConnect(token, channelName, {
|
const payload = await startChannelConnect(tokenRef.current, channelName, {
|
||||||
domain: startDomain,
|
domain: startDomain,
|
||||||
instanceId: startInstanceId,
|
instanceId: startInstanceId,
|
||||||
mode: startMode,
|
mode: startMode,
|
||||||
@ -147,7 +160,7 @@ export function ChannelQrConnectFlow({
|
|||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
}, [channelName, startDomain, startForce, startInstanceId, startMode, token]);
|
}, [channelName, startDomain, startForce, startInstanceId, startMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!connectRequestId || connectRequestId === handledRequestId) return;
|
if (!connectRequestId || connectRequestId === handledRequestId) return;
|
||||||
@ -162,7 +175,11 @@ export function ChannelQrConnectFlow({
|
|||||||
}
|
}
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
const payload = await cancelChannelConnect(token, channelName, connect.session_id);
|
const payload = await cancelChannelConnect(
|
||||||
|
tokenRef.current,
|
||||||
|
channelName,
|
||||||
|
connect.session_id,
|
||||||
|
);
|
||||||
setConnect(payload);
|
setConnect(payload);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError((err as Error).message);
|
setError((err as Error).message);
|
||||||
|
|||||||
@ -496,7 +496,7 @@ interface PendingFirstMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface InstalledSettingItemsOptions<Payload, Item> {
|
interface InstalledSettingItemsOptions<Payload, Item> {
|
||||||
token: string;
|
getToken: () => string;
|
||||||
eventName: string;
|
eventName: string;
|
||||||
fetchPayload: (token: string) => Promise<Payload>;
|
fetchPayload: (token: string) => Promise<Payload>;
|
||||||
isPayload: (value: unknown) => value is Payload;
|
isPayload: (value: unknown) => value is Payload;
|
||||||
@ -504,7 +504,7 @@ interface InstalledSettingItemsOptions<Payload, Item> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function useInstalledSettingItems<Payload, Item>({
|
function useInstalledSettingItems<Payload, Item>({
|
||||||
token,
|
getToken,
|
||||||
eventName,
|
eventName,
|
||||||
fetchPayload,
|
fetchPayload,
|
||||||
isPayload,
|
isPayload,
|
||||||
@ -512,42 +512,65 @@ function useInstalledSettingItems<Payload, Item>({
|
|||||||
}: InstalledSettingItemsOptions<Payload, Item>): Item[] {
|
}: InstalledSettingItemsOptions<Payload, Item>): Item[] {
|
||||||
const [items, setItems] = useState<Item[]>([]);
|
const [items, setItems] = useState<Item[]>([]);
|
||||||
|
|
||||||
const refresh = useCallback(async (isCancelled?: () => boolean) => {
|
|
||||||
try {
|
|
||||||
const payload = await fetchPayload(token);
|
|
||||||
if (!isCancelled?.()) setItems(selectItems(payload));
|
|
||||||
} catch {
|
|
||||||
// Keep the last successful catalog during transient focus/visibility refresh failures.
|
|
||||||
}
|
|
||||||
}, [fetchPayload, selectItems, token]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
void refresh(() => cancelled);
|
let refreshQueued = false;
|
||||||
|
let refreshAfterFlight = false;
|
||||||
const refreshOnFocus = () => {
|
let refreshing = false;
|
||||||
if (document.visibilityState === "hidden") return;
|
let payloadVersion = 0;
|
||||||
void refresh();
|
const refresh = async (): Promise<void> => {
|
||||||
|
if (refreshing) return;
|
||||||
|
refreshing = true;
|
||||||
|
const version = payloadVersion;
|
||||||
|
try {
|
||||||
|
const payload = await fetchPayload(getToken());
|
||||||
|
if (!cancelled && version === payloadVersion) {
|
||||||
|
setItems(selectItems(payload));
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Keep the last successful catalog during transient refresh failures.
|
||||||
|
} finally {
|
||||||
|
refreshing = false;
|
||||||
|
if (refreshAfterFlight && !cancelled) {
|
||||||
|
refreshAfterFlight = false;
|
||||||
|
void refresh();
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
const queueRefresh = () => {
|
||||||
|
if (document.visibilityState === "hidden" || refreshQueued) return;
|
||||||
|
refreshQueued = true;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
refreshQueued = false;
|
||||||
|
if (!cancelled) void refresh();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
void refresh();
|
||||||
|
|
||||||
const refreshOnChanged = (event: Event) => {
|
const refreshOnChanged = (event: Event) => {
|
||||||
const payload = (event as CustomEvent<unknown>).detail;
|
const payload = (event as CustomEvent<unknown>).detail;
|
||||||
if (isPayload(payload)) {
|
if (isPayload(payload)) {
|
||||||
|
payloadVersion += 1;
|
||||||
setItems(selectItems(payload));
|
setItems(selectItems(payload));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void refresh();
|
if (refreshing) {
|
||||||
|
refreshAfterFlight = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
queueRefresh();
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener("focus", refreshOnFocus);
|
window.addEventListener("focus", queueRefresh);
|
||||||
document.addEventListener("visibilitychange", refreshOnFocus);
|
document.addEventListener("visibilitychange", queueRefresh);
|
||||||
window.addEventListener(eventName, refreshOnChanged);
|
window.addEventListener(eventName, refreshOnChanged);
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
window.removeEventListener("focus", refreshOnFocus);
|
window.removeEventListener("focus", queueRefresh);
|
||||||
document.removeEventListener("visibilitychange", refreshOnFocus);
|
document.removeEventListener("visibilitychange", queueRefresh);
|
||||||
window.removeEventListener(eventName, refreshOnChanged);
|
window.removeEventListener(eventName, refreshOnChanged);
|
||||||
};
|
};
|
||||||
}, [eventName, isPayload, refresh, selectItems]);
|
}, [eventName, fetchPayload, getToken, isPayload, selectItems]);
|
||||||
|
|
||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
@ -581,6 +604,7 @@ export function ThreadShell({
|
|||||||
const {
|
const {
|
||||||
messages: historical,
|
messages: historical,
|
||||||
loading,
|
loading,
|
||||||
|
error: historyError,
|
||||||
loadingOlder,
|
loadingOlder,
|
||||||
loadOlder,
|
loadOlder,
|
||||||
hasMoreBefore,
|
hasMoreBefore,
|
||||||
@ -594,19 +618,19 @@ export function ThreadShell({
|
|||||||
version: historyVersion,
|
version: historyVersion,
|
||||||
forkBoundaryMessageCount,
|
forkBoundaryMessageCount,
|
||||||
} = useSessionHistory(historyKey);
|
} = useSessionHistory(historyKey);
|
||||||
const { client, ingressLimits, modelName, token } = useClient();
|
const { client, getToken, ingressLimits, modelName, token } = useClient();
|
||||||
const [fallbackModelName, setFallbackModelName] = useState<string | null>(null);
|
const [fallbackModelName, setFallbackModelName] = useState<string | null>(null);
|
||||||
const [booting, setBooting] = useState(false);
|
const [booting, setBooting] = useState(false);
|
||||||
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
|
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
|
||||||
const cliApps = useInstalledSettingItems({
|
const cliApps = useInstalledSettingItems({
|
||||||
token,
|
getToken,
|
||||||
eventName: CLI_APPS_CHANGED_EVENT,
|
eventName: CLI_APPS_CHANGED_EVENT,
|
||||||
fetchPayload: fetchInstalledCliApps,
|
fetchPayload: fetchInstalledCliApps,
|
||||||
isPayload: isCliAppsPayload,
|
isPayload: isCliAppsPayload,
|
||||||
selectItems: installedCliAppsFromPayload,
|
selectItems: installedCliAppsFromPayload,
|
||||||
});
|
});
|
||||||
const mcpPresets = useInstalledSettingItems({
|
const mcpPresets = useInstalledSettingItems({
|
||||||
token,
|
getToken,
|
||||||
eventName: MCP_PRESETS_CHANGED_EVENT,
|
eventName: MCP_PRESETS_CHANGED_EVENT,
|
||||||
fetchPayload: fetchMcpPresets,
|
fetchPayload: fetchMcpPresets,
|
||||||
isPayload: isMcpPresetsPayload,
|
isPayload: isMcpPresetsPayload,
|
||||||
@ -738,7 +762,7 @@ export function ThreadShell({
|
|||||||
}, [chatId, messagesReady, rememberedViewportTurnId, turnActive]);
|
}, [chatId, messagesReady, rememberedViewportTurnId, turnActive]);
|
||||||
const filePreviewAvailabilityCache = useMemo(
|
const filePreviewAvailabilityCache = useMemo(
|
||||||
() => new Map<string, FilePreviewAvailabilityCacheEntry>(),
|
() => new Map<string, FilePreviewAvailabilityCacheEntry>(),
|
||||||
[historyKey, token],
|
[historyKey],
|
||||||
);
|
);
|
||||||
const filePreviewAvailabilityRevision = displayMessages.length;
|
const filePreviewAvailabilityRevision = displayMessages.length;
|
||||||
const resolveFilePreviewAvailability = useCallback((path: string) => {
|
const resolveFilePreviewAvailability = useCallback((path: string) => {
|
||||||
@ -750,7 +774,7 @@ export function ThreadShell({
|
|||||||
) {
|
) {
|
||||||
return cached.promise;
|
return cached.promise;
|
||||||
}
|
}
|
||||||
const pending = fetchFilePreviewAvailability(token, historyKey, path).catch(
|
const pending = fetchFilePreviewAvailability(getToken(), historyKey, path).catch(
|
||||||
(error: unknown) => {
|
(error: unknown) => {
|
||||||
if (error instanceof ApiError) {
|
if (error instanceof ApiError) {
|
||||||
if (error.status === 404 && /API route not found/i.test(error.message)) {
|
if (error.status === 404 && /API route not found/i.test(error.message)) {
|
||||||
@ -775,8 +799,8 @@ export function ThreadShell({
|
|||||||
}, [
|
}, [
|
||||||
filePreviewAvailabilityCache,
|
filePreviewAvailabilityCache,
|
||||||
filePreviewAvailabilityRevision,
|
filePreviewAvailabilityRevision,
|
||||||
|
getToken,
|
||||||
historyKey,
|
historyKey,
|
||||||
token,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const showHeroComposer = displayMessages.length === 0 && !loading;
|
const showHeroComposer = displayMessages.length === 0 && !loading;
|
||||||
@ -829,11 +853,11 @@ export function ThreadShell({
|
|||||||
|
|
||||||
const refreshModelSettings = useCallback(async () => {
|
const refreshModelSettings = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
setSettings(await fetchSettings(token));
|
setSettings(await fetchSettings(getToken()));
|
||||||
} catch {
|
} catch {
|
||||||
if (!settingsSnapshot) setSettings(null);
|
if (!settingsSnapshot) setSettings(null);
|
||||||
}
|
}
|
||||||
}, [settingsSnapshot, token]);
|
}, [getToken, settingsSnapshot]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (settingsSnapshot) {
|
if (settingsSnapshot) {
|
||||||
@ -1067,14 +1091,37 @@ export function ThreadShell({
|
|||||||
});
|
});
|
||||||
}, [chatId, client, refreshCanonicalHistory]);
|
}, [chatId, client, refreshCanonicalHistory]);
|
||||||
|
|
||||||
|
const wasPageHiddenRef = useRef(document.visibilityState === "hidden");
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const refreshOnReturn = () => {
|
const refreshOnReturn = () => {
|
||||||
if (document.visibilityState !== "visible") return;
|
if (document.visibilityState === "hidden") {
|
||||||
|
wasPageHiddenRef.current = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!wasPageHiddenRef.current) return;
|
||||||
|
wasPageHiddenRef.current = false;
|
||||||
|
if (!chatId || client.status !== "open" || loading) return;
|
||||||
|
if (
|
||||||
|
!turnActive
|
||||||
|
&& !hasPendingToolCalls
|
||||||
|
&& !client.hasUnsettledRun(chatId)
|
||||||
|
&& !historyError
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
refreshCanonicalHistory();
|
refreshCanonicalHistory();
|
||||||
};
|
};
|
||||||
document.addEventListener("visibilitychange", refreshOnReturn);
|
document.addEventListener("visibilitychange", refreshOnReturn);
|
||||||
return () => document.removeEventListener("visibilitychange", refreshOnReturn);
|
return () => document.removeEventListener("visibilitychange", refreshOnReturn);
|
||||||
}, [refreshCanonicalHistory]);
|
}, [
|
||||||
|
chatId,
|
||||||
|
client,
|
||||||
|
hasPendingToolCalls,
|
||||||
|
historyError,
|
||||||
|
loading,
|
||||||
|
refreshCanonicalHistory,
|
||||||
|
turnActive,
|
||||||
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let refreshOnNextOpen = client.status !== "open";
|
let refreshOnNextOpen = client.status !== "open";
|
||||||
@ -1154,7 +1201,7 @@ export function ThreadShell({
|
|||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const commands = await listSlashCommands(token);
|
const commands = await listSlashCommands(getToken());
|
||||||
if (!cancelled) setSlashCommands(commands);
|
if (!cancelled) setSlashCommands(commands);
|
||||||
} catch {
|
} catch {
|
||||||
if (!cancelled) setSlashCommands([]);
|
if (!cancelled) setSlashCommands([]);
|
||||||
@ -1163,7 +1210,7 @@ export function ThreadShell({
|
|||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [token]);
|
}, [getToken]);
|
||||||
|
|
||||||
const handleWelcomeSend = useCallback(
|
const handleWelcomeSend = useCallback(
|
||||||
async (content: string, images?: SendAttachment[], options?: SendOptions) => {
|
async (content: string, images?: SendAttachment[], options?: SendOptions) => {
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
import { usePageVisibility } from "@/hooks/usePageVisibility";
|
import { usePageVisibility } from "@/hooks/usePageVisibility";
|
||||||
import { fetchSessionAutomations } from "@/lib/api";
|
import { fetchSessionAutomations } from "@/lib/api";
|
||||||
@ -12,20 +12,25 @@ export function useSessionAutomationJobs(open: boolean, token: string, sessionKe
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [loadFailed, setLoadFailed] = useState(false);
|
const [loadFailed, setLoadFailed] = useState(false);
|
||||||
const [now, setNow] = useState(() => Date.now());
|
const [now, setNow] = useState(() => Date.now());
|
||||||
|
const tokenRef = useRef(token);
|
||||||
|
tokenRef.current = token;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !pageVisible) return;
|
if (!open || !pageVisible) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
let loadedOnce = false;
|
let loadedOnce = false;
|
||||||
|
let refreshing = false;
|
||||||
|
|
||||||
const refresh = async (showLoading = false) => {
|
const refresh = async (showLoading = false) => {
|
||||||
|
if (refreshing) return;
|
||||||
|
refreshing = true;
|
||||||
if (showLoading) {
|
if (showLoading) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setLoadFailed(false);
|
setLoadFailed(false);
|
||||||
setJobs([]);
|
setJobs([]);
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const next = await fetchSessionAutomations(token, sessionKey);
|
const next = await fetchSessionAutomations(tokenRef.current, sessionKey);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setJobs(next.jobs);
|
setJobs(next.jobs);
|
||||||
setLoadFailed(false);
|
setLoadFailed(false);
|
||||||
@ -33,6 +38,7 @@ export function useSessionAutomationJobs(open: boolean, token: string, sessionKe
|
|||||||
} catch {
|
} catch {
|
||||||
if (!cancelled && !loadedOnce) setLoadFailed(true);
|
if (!cancelled && !loadedOnce) setLoadFailed(true);
|
||||||
} finally {
|
} finally {
|
||||||
|
refreshing = false;
|
||||||
if (!cancelled && showLoading) setLoading(false);
|
if (!cancelled && showLoading) setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -46,7 +52,7 @@ export function useSessionAutomationJobs(open: boolean, token: string, sessionKe
|
|||||||
window.clearInterval(refreshId);
|
window.clearInterval(refreshId);
|
||||||
window.removeEventListener("focus", refreshOnFocus);
|
window.removeEventListener("focus", refreshOnFocus);
|
||||||
};
|
};
|
||||||
}, [open, pageVisible, sessionKey, token]);
|
}, [open, pageVisible, sessionKey]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !pageVisible) return;
|
if (!open || !pageVisible) return;
|
||||||
|
|||||||
@ -24,6 +24,15 @@ const INITIAL_HISTORY_PAGE_LIMIT = 160;
|
|||||||
const OLDER_HISTORY_PAGE_LIMIT = 120;
|
const OLDER_HISTORY_PAGE_LIMIT = 120;
|
||||||
const CHAT_CREATE_TIMEOUT_MS = 60_000;
|
const CHAT_CREATE_TIMEOUT_MS = 60_000;
|
||||||
|
|
||||||
|
function isAbortError(error: unknown): boolean {
|
||||||
|
return (
|
||||||
|
typeof error === "object"
|
||||||
|
&& error !== null
|
||||||
|
&& "name" in error
|
||||||
|
&& error.name === "AbortError"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export type SessionHistoryContinuity = "initial" | "overlap" | "reset";
|
export type SessionHistoryContinuity = "initial" | "overlap" | "reset";
|
||||||
|
|
||||||
function persistedMessagesToUi(messages: UIMessage[]): UIMessage[] {
|
function persistedMessagesToUi(messages: UIMessage[]): UIMessage[] {
|
||||||
@ -132,32 +141,46 @@ export function useSessions(): {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const tokenRef = useRef(token);
|
const tokenRef = useRef(token);
|
||||||
const optimisticKeysRef = useRef<Set<string>>(new Set());
|
const optimisticKeysRef = useRef<Set<string>>(new Set());
|
||||||
|
const refreshPendingRef = useRef(false);
|
||||||
|
const refreshInFlightRef = useRef<Promise<void> | null>(null);
|
||||||
tokenRef.current = token;
|
tokenRef.current = token;
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback((): Promise<void> => {
|
||||||
try {
|
refreshPendingRef.current = true;
|
||||||
|
if (refreshInFlightRef.current) return refreshInFlightRef.current;
|
||||||
|
const request = (async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const rows = await listSessions(tokenRef.current);
|
try {
|
||||||
const serverKeys = new Set(rows.map((row) => row.key));
|
while (refreshPendingRef.current) {
|
||||||
setSessions((prev) => [
|
refreshPendingRef.current = false;
|
||||||
...rows,
|
try {
|
||||||
...prev.filter(
|
const rows = await listSessions(tokenRef.current);
|
||||||
(session) =>
|
const serverKeys = new Set(rows.map((row) => row.key));
|
||||||
optimisticKeysRef.current.has(session.key) &&
|
setSessions((prev) => [
|
||||||
!serverKeys.has(session.key),
|
...rows,
|
||||||
),
|
...prev.filter(
|
||||||
]);
|
(session) =>
|
||||||
for (const key of Array.from(optimisticKeysRef.current)) {
|
optimisticKeysRef.current.has(session.key)
|
||||||
if (serverKeys.has(key)) optimisticKeysRef.current.delete(key);
|
&& !serverKeys.has(session.key),
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
for (const key of Array.from(optimisticKeysRef.current)) {
|
||||||
|
if (serverKeys.has(key)) optimisticKeysRef.current.delete(key);
|
||||||
|
}
|
||||||
|
setError(null);
|
||||||
|
} catch (e) {
|
||||||
|
const msg =
|
||||||
|
e instanceof ApiError ? `HTTP ${e.status}` : (e as Error).message;
|
||||||
|
setError(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
refreshInFlightRef.current = null;
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
setError(null);
|
})();
|
||||||
} catch (e) {
|
refreshInFlightRef.current = request;
|
||||||
const msg =
|
return request;
|
||||||
e instanceof ApiError ? `HTTP ${e.status}` : (e as Error).message;
|
|
||||||
setError(msg);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -165,9 +188,20 @@ export function useSessions(): {
|
|||||||
}, [refresh]);
|
}, [refresh]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
return client.onSessionUpdate(() => {
|
let disposed = false;
|
||||||
void refresh();
|
let refreshQueued = false;
|
||||||
|
const unsubscribe = client.onSessionUpdate(() => {
|
||||||
|
if (refreshQueued) return;
|
||||||
|
refreshQueued = true;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
refreshQueued = false;
|
||||||
|
if (!disposed) void refresh();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
return () => {
|
||||||
|
disposed = true;
|
||||||
|
unsubscribe();
|
||||||
|
};
|
||||||
}, [client, refresh]);
|
}, [client, refresh]);
|
||||||
|
|
||||||
const createChat = useCallback(async (workspaceScope?: WorkspaceScopePayload | null): Promise<string> => {
|
const createChat = useCallback(async (workspaceScope?: WorkspaceScopePayload | null): Promise<string> => {
|
||||||
@ -272,8 +306,9 @@ export function useSessionHistory(key: string | null): {
|
|||||||
/** Exact active turn when supplied by a current gateway. */
|
/** Exact active turn when supplied by a current gateway. */
|
||||||
activeTurnId: string | null;
|
activeTurnId: string | null;
|
||||||
} {
|
} {
|
||||||
const { token } = useClient();
|
const { getToken } = useClient();
|
||||||
const loadingOlderRef = useRef(false);
|
const loadingOlderRef = useRef(false);
|
||||||
|
const olderRequestAbortRef = useRef<AbortController | null>(null);
|
||||||
const historyVersionRef = useRef(0);
|
const historyVersionRef = useRef(0);
|
||||||
const [refreshSeq, setRefreshSeq] = useState(0);
|
const [refreshSeq, setRefreshSeq] = useState(0);
|
||||||
const refresh = useCallback(() => {
|
const refresh = useCallback(() => {
|
||||||
@ -313,8 +348,17 @@ export function useSessionHistory(key: string | null): {
|
|||||||
activeTurnId: null,
|
activeTurnId: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
olderRequestAbortRef.current?.abort();
|
||||||
|
olderRequestAbortRef.current = null;
|
||||||
|
loadingOlderRef.current = false;
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!key) {
|
if (!key) {
|
||||||
|
olderRequestAbortRef.current?.abort();
|
||||||
|
olderRequestAbortRef.current = null;
|
||||||
|
loadingOlderRef.current = false;
|
||||||
setState({
|
setState({
|
||||||
key: null,
|
key: null,
|
||||||
messages: [],
|
messages: [],
|
||||||
@ -335,6 +379,10 @@ export function useSessionHistory(key: string | null): {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
const controller = new AbortController();
|
||||||
|
olderRequestAbortRef.current?.abort();
|
||||||
|
olderRequestAbortRef.current = null;
|
||||||
|
loadingOlderRef.current = false;
|
||||||
// Mark the new key as loading immediately so callers never see stale
|
// Mark the new key as loading immediately so callers never see stale
|
||||||
// messages from the previous session during the render right after a switch.
|
// messages from the previous session during the render right after a switch.
|
||||||
setState((prev) => prev.key === key
|
setState((prev) => prev.key === key
|
||||||
@ -358,9 +406,10 @@ export function useSessionHistory(key: string | null): {
|
|||||||
});
|
});
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const body = await fetchWebuiThread(token, key, {
|
const body = await fetchWebuiThread(getToken(), key, {
|
||||||
limit: INITIAL_HISTORY_PAGE_LIMIT,
|
limit: INITIAL_HISTORY_PAGE_LIMIT,
|
||||||
direction: "latest",
|
direction: "latest",
|
||||||
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
historyVersionRef.current += 1;
|
historyVersionRef.current += 1;
|
||||||
@ -414,7 +463,7 @@ export function useSessionHistory(key: string | null): {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (cancelled) return;
|
if (cancelled || isAbortError(e)) return;
|
||||||
if (e instanceof ApiError && e.status === 404) {
|
if (e instanceof ApiError && e.status === 404) {
|
||||||
historyVersionRef.current += 1;
|
historyVersionRef.current += 1;
|
||||||
const responseVersion = historyVersionRef.current;
|
const responseVersion = historyVersionRef.current;
|
||||||
@ -463,8 +512,9 @@ export function useSessionHistory(key: string | null): {
|
|||||||
})();
|
})();
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
controller.abort();
|
||||||
};
|
};
|
||||||
}, [key, token, refreshSeq]);
|
}, [getToken, key, refreshSeq]);
|
||||||
|
|
||||||
const loadOlder = useCallback(async () => {
|
const loadOlder = useCallback(async () => {
|
||||||
if (!key || loadingOlderRef.current) return;
|
if (!key || loadingOlderRef.current) return;
|
||||||
@ -478,13 +528,16 @@ export function useSessionHistory(key: string | null): {
|
|||||||
&& candidate.beforeCursor === beforeCursor
|
&& candidate.beforeCursor === beforeCursor
|
||||||
);
|
);
|
||||||
loadingOlderRef.current = true;
|
loadingOlderRef.current = true;
|
||||||
|
const controller = new AbortController();
|
||||||
|
olderRequestAbortRef.current = controller;
|
||||||
setState((prev) => matchesRequest(prev)
|
setState((prev) => matchesRequest(prev)
|
||||||
? { ...prev, loadingOlder: true, error: null }
|
? { ...prev, loadingOlder: true, error: null }
|
||||||
: prev);
|
: prev);
|
||||||
try {
|
try {
|
||||||
const body = await fetchWebuiThread(token, requestKey, {
|
const body = await fetchWebuiThread(getToken(), requestKey, {
|
||||||
limit: OLDER_HISTORY_PAGE_LIMIT,
|
limit: OLDER_HISTORY_PAGE_LIMIT,
|
||||||
before: beforeCursor,
|
before: beforeCursor,
|
||||||
|
signal: controller.signal,
|
||||||
});
|
});
|
||||||
setState((prev) => {
|
setState((prev) => {
|
||||||
if (!matchesRequest(prev)) return prev;
|
if (!matchesRequest(prev)) return prev;
|
||||||
@ -518,6 +571,7 @@ export function useSessionHistory(key: string | null): {
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
if (isAbortError(e)) return;
|
||||||
setState((prev) => matchesRequest(prev)
|
setState((prev) => matchesRequest(prev)
|
||||||
? {
|
? {
|
||||||
...prev,
|
...prev,
|
||||||
@ -526,7 +580,10 @@ export function useSessionHistory(key: string | null): {
|
|||||||
}
|
}
|
||||||
: prev);
|
: prev);
|
||||||
} finally {
|
} finally {
|
||||||
loadingOlderRef.current = false;
|
if (olderRequestAbortRef.current === controller) {
|
||||||
|
olderRequestAbortRef.current = null;
|
||||||
|
loadingOlderRef.current = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
key,
|
key,
|
||||||
@ -534,7 +591,7 @@ export function useSessionHistory(key: string | null): {
|
|||||||
state.hasMoreBefore,
|
state.hasMoreBefore,
|
||||||
state.key,
|
state.key,
|
||||||
state.lineage,
|
state.lineage,
|
||||||
token,
|
getToken,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!key) {
|
if (!key) {
|
||||||
|
|||||||
@ -4,19 +4,28 @@ import { fetchSkills } from "@/lib/api";
|
|||||||
import { isSkillsPayload, SKILLS_CHANGED_EVENT } from "@/lib/skill-events";
|
import { isSkillsPayload, SKILLS_CHANGED_EVENT } from "@/lib/skill-events";
|
||||||
import type { SkillSummary } from "@/lib/types";
|
import type { SkillSummary } from "@/lib/types";
|
||||||
|
|
||||||
export function useSkills(token: string): SkillSummary[] {
|
export function useSkills(getToken: () => string): SkillSummary[] {
|
||||||
const [skills, setSkills] = useState<SkillSummary[]>([]);
|
const [skills, setSkills] = useState<SkillSummary[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
let payloadVersion = 0;
|
||||||
const refresh = () => {
|
const refresh = () => {
|
||||||
fetchSkills(token)
|
const version = payloadVersion;
|
||||||
.then(({ skills: nextSkills }) => !cancelled && setSkills(nextSkills))
|
fetchSkills(getToken())
|
||||||
.catch(() => !cancelled && setSkills([]));
|
.then(({ skills: nextSkills }) => {
|
||||||
|
if (!cancelled && version === payloadVersion) setSkills(nextSkills);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled && version === payloadVersion) setSkills([]);
|
||||||
|
});
|
||||||
};
|
};
|
||||||
const onSkillsChanged = (event: Event) => {
|
const onSkillsChanged = (event: Event) => {
|
||||||
const payload = (event as CustomEvent<unknown>).detail;
|
const payload = (event as CustomEvent<unknown>).detail;
|
||||||
if (!cancelled && isSkillsPayload(payload)) setSkills(payload.skills);
|
if (!cancelled && isSkillsPayload(payload)) {
|
||||||
|
payloadVersion += 1;
|
||||||
|
setSkills(payload.skills);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
refresh();
|
refresh();
|
||||||
@ -25,7 +34,7 @@ export function useSkills(token: string): SkillSummary[] {
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
window.removeEventListener(SKILLS_CHANGED_EVENT, onSkillsChanged);
|
window.removeEventListener(SKILLS_CHANGED_EVENT, onSkillsChanged);
|
||||||
};
|
};
|
||||||
}, [token]);
|
}, [getToken]);
|
||||||
|
|
||||||
return skills;
|
return skills;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -171,6 +171,7 @@ export interface FetchWebuiThreadOptions {
|
|||||||
limit?: number;
|
limit?: number;
|
||||||
direction?: "latest";
|
direction?: "latest";
|
||||||
before?: string | null;
|
before?: string | null;
|
||||||
|
signal?: AbortSignal;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchWebuiThread(
|
export async function fetchWebuiThread(
|
||||||
@ -192,6 +193,7 @@ export async function fetchWebuiThread(
|
|||||||
headers: { Authorization: `Bearer ${token}` },
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
credentials: "same-origin",
|
credentials: "same-origin",
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
|
signal: options?.signal,
|
||||||
});
|
});
|
||||||
if (res.status === 404) return null;
|
if (res.status === 404) return null;
|
||||||
if (!res.ok) throw new ApiError(res.status, `HTTP ${res.status}`);
|
if (!res.ok) throw new ApiError(res.status, `HTTP ${res.status}`);
|
||||||
|
|||||||
@ -12,22 +12,32 @@ export async function fetchWithTimeout(
|
|||||||
const controller = typeof AbortController !== "undefined"
|
const controller = typeof AbortController !== "undefined"
|
||||||
? new AbortController()
|
? new AbortController()
|
||||||
: null;
|
: null;
|
||||||
|
const externalSignal = init.signal;
|
||||||
|
const abortFromExternal = () => controller?.abort();
|
||||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
const request = fetch(input, {
|
if (controller && externalSignal) {
|
||||||
...init,
|
if (externalSignal.aborted) {
|
||||||
signal: controller?.signal ?? init.signal,
|
controller.abort();
|
||||||
});
|
} else {
|
||||||
const timeout = new Promise<Response>((_, reject) => {
|
externalSignal.addEventListener("abort", abortFromExternal, { once: true });
|
||||||
timeoutId = setTimeout(() => {
|
}
|
||||||
reject(new Error(`Request timed out after ${timeoutMs}ms`));
|
}
|
||||||
controller?.abort();
|
|
||||||
}, timeoutMs);
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
const request = fetch(input, {
|
||||||
|
...init,
|
||||||
|
signal: controller?.signal ?? externalSignal,
|
||||||
|
});
|
||||||
|
const timeout = new Promise<Response>((_, reject) => {
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
reject(new Error(`Request timed out after ${timeoutMs}ms`));
|
||||||
|
controller?.abort();
|
||||||
|
}, timeoutMs);
|
||||||
|
});
|
||||||
return await Promise.race([request, timeout]);
|
return await Promise.race([request, timeout]);
|
||||||
} finally {
|
} finally {
|
||||||
if (timeoutId !== undefined) clearTimeout(timeoutId);
|
if (timeoutId !== undefined) clearTimeout(timeoutId);
|
||||||
|
externalSignal?.removeEventListener("abort", abortFromExternal);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,11 @@
|
|||||||
import { createContext, useContext, type ReactNode } from "react";
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
type ReactNode,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
import type { NanobotClient } from "@/lib/nanobot-client";
|
import type { NanobotClient } from "@/lib/nanobot-client";
|
||||||
import type { WebUIIngressLimits } from "@/lib/types";
|
import type { WebUIIngressLimits } from "@/lib/types";
|
||||||
@ -6,6 +13,7 @@ import type { WebUIIngressLimits } from "@/lib/types";
|
|||||||
interface ClientContextValue {
|
interface ClientContextValue {
|
||||||
client: NanobotClient;
|
client: NanobotClient;
|
||||||
token: string;
|
token: string;
|
||||||
|
getToken: () => string;
|
||||||
modelName: string | null;
|
modelName: string | null;
|
||||||
ingressLimits: WebUIIngressLimits | null;
|
ingressLimits: WebUIIngressLimits | null;
|
||||||
}
|
}
|
||||||
@ -25,8 +33,16 @@ export function ClientProvider({
|
|||||||
ingressLimits?: WebUIIngressLimits | null;
|
ingressLimits?: WebUIIngressLimits | null;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
|
const tokenRef = useRef(token);
|
||||||
|
tokenRef.current = token;
|
||||||
|
const getToken = useCallback(() => tokenRef.current, []);
|
||||||
|
const value = useMemo(
|
||||||
|
() => ({ client, token, getToken, modelName, ingressLimits }),
|
||||||
|
[client, getToken, ingressLimits, modelName, token],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ClientContext.Provider value={{ client, token, modelName, ingressLimits }}>
|
<ClientContext.Provider value={value}>
|
||||||
{children}
|
{children}
|
||||||
</ClientContext.Provider>
|
</ClientContext.Provider>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -103,6 +103,25 @@ describe("webui API helpers", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("aborts a WebUI thread request when its caller signal is aborted", async () => {
|
||||||
|
let requestSignal: AbortSignal | null = null;
|
||||||
|
vi.mocked(fetch).mockImplementation((_input, init) => new Promise((_resolve, reject) => {
|
||||||
|
requestSignal = init?.signal ?? null;
|
||||||
|
requestSignal?.addEventListener("abort", () => {
|
||||||
|
reject(new DOMException("Aborted", "AbortError"));
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
const request = fetchWebuiThread("tok", "websocket:chat-1", {
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
controller.abort();
|
||||||
|
|
||||||
|
await expect(request).rejects.toMatchObject({ name: "AbortError" });
|
||||||
|
expect(requestSignal?.aborted).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("percent-encodes websocket keys and paths when fetching file previews", async () => {
|
it("percent-encodes websocket keys and paths when fetching file previews", async () => {
|
||||||
await fetchFilePreview("tok", "websocket:chat-1", "/tmp/project/hook.py:12");
|
await fetchFilePreview("tok", "websocket:chat-1", "/tmp/project/hook.py:12");
|
||||||
|
|
||||||
|
|||||||
@ -2642,4 +2642,54 @@ describe("App layout", () => {
|
|||||||
expect(updateUrlSpy).toHaveBeenCalledWith("ws://test?token=tok-2");
|
expect(updateUrlSpy).toHaveBeenCalledWith("ws://test?token=tok-2");
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("reuses an in-flight pairing poll when the page becomes visible again", async () => {
|
||||||
|
let resolvePairing!: (response: Response) => void;
|
||||||
|
const pendingPairing = new Promise<Response>((resolve) => {
|
||||||
|
resolvePairing = resolve;
|
||||||
|
});
|
||||||
|
const fetchMock = vi.fn((input: RequestInfo | URL) => (
|
||||||
|
String(input) === "/api/settings/pairing"
|
||||||
|
? pendingPairing
|
||||||
|
: Promise.resolve({ ok: false, status: 404 } as Response)
|
||||||
|
));
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
const visibilityDescriptor = Object.getOwnPropertyDescriptor(document, "visibilityState");
|
||||||
|
|
||||||
|
const setVisibility = (state: DocumentVisibilityState) => {
|
||||||
|
Object.defineProperty(document, "visibilityState", {
|
||||||
|
configurable: true,
|
||||||
|
value: state,
|
||||||
|
});
|
||||||
|
document.dispatchEvent(new Event("visibilitychange"));
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
render(<App />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(fetchMock.mock.calls.filter(([input]) => (
|
||||||
|
String(input) === "/api/settings/pairing"
|
||||||
|
))).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => setVisibility("hidden"));
|
||||||
|
act(() => setVisibility("visible"));
|
||||||
|
|
||||||
|
expect(fetchMock.mock.calls.filter(([input]) => (
|
||||||
|
String(input) === "/api/settings/pairing"
|
||||||
|
))).toHaveLength(1);
|
||||||
|
await act(async () => {
|
||||||
|
resolvePairing(jsonResponse({ requests: [] }));
|
||||||
|
await pendingPairing;
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
if (visibilityDescriptor) {
|
||||||
|
Object.defineProperty(document, "visibilityState", visibilityDescriptor);
|
||||||
|
} else {
|
||||||
|
delete (document as Document & {
|
||||||
|
visibilityState?: DocumentVisibilityState;
|
||||||
|
}).visibilityState;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { act, render, screen } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { FilePreviewPanel } from "@/components/FilePreviewPanel";
|
import { FilePreviewPanel } from "@/components/FilePreviewPanel";
|
||||||
|
import { setAppLanguage } from "@/i18n";
|
||||||
import { fetchFilePreview } from "@/lib/api";
|
import { fetchFilePreview } from "@/lib/api";
|
||||||
|
|
||||||
vi.mock("@/components/CodeBlock", () => ({
|
vi.mock("@/components/CodeBlock", () => ({
|
||||||
@ -34,7 +35,8 @@ vi.mock("@/lib/api", async (importOriginal) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("FilePreviewPanel", () => {
|
describe("FilePreviewPanel", () => {
|
||||||
beforeEach(() => {
|
beforeEach(async () => {
|
||||||
|
await setAppLanguage("en");
|
||||||
vi.mocked(fetchFilePreview).mockReset();
|
vi.mocked(fetchFilePreview).mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -73,4 +75,32 @@ describe("FilePreviewPanel", () => {
|
|||||||
await user.click(closeButton);
|
await user.click(closeButton);
|
||||||
expect(onClose).toHaveBeenCalledTimes(1);
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("updates translated chrome without refetching the open file", async () => {
|
||||||
|
vi.mocked(fetchFilePreview).mockResolvedValue({
|
||||||
|
path: "/workspace/notes.md",
|
||||||
|
display_path: "notes.md",
|
||||||
|
language: "markdown",
|
||||||
|
content: "# Notes",
|
||||||
|
truncated: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<FilePreviewPanel
|
||||||
|
sessionKey="websocket:chat-1"
|
||||||
|
path="notes.md"
|
||||||
|
token="tok"
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await screen.findByTestId("mock-code-block");
|
||||||
|
expect(fetchFilePreview).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
await setAppLanguage("zh-CN");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fetchFilePreview).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { render, screen, waitFor } from "@testing-library/react";
|
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
@ -141,4 +141,34 @@ describe("SessionInfoPopover", () => {
|
|||||||
);
|
);
|
||||||
expect(screen.getByText("No automations in this session yet.")).toBeInTheDocument();
|
expect(screen.getByText("No automations in this session yet.")).toBeInTheDocument();
|
||||||
}, 8000);
|
}, 8000);
|
||||||
|
|
||||||
|
it("coalesces focus refreshes while a session automation request is in flight", async () => {
|
||||||
|
let resolveRequest!: (response: Response) => void;
|
||||||
|
const pendingRequest = new Promise<Response>((resolve) => {
|
||||||
|
resolveRequest = resolve;
|
||||||
|
});
|
||||||
|
const fetchMock = vi.fn(() => pendingRequest);
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<SessionInfoPopover
|
||||||
|
sessionKey="websocket:chat-1"
|
||||||
|
token="tok"
|
||||||
|
title="Release work"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: "Session details" }));
|
||||||
|
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
|
||||||
|
|
||||||
|
window.dispatchEvent(new Event("focus"));
|
||||||
|
window.dispatchEvent(new Event("focus"));
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||||
|
await act(async () => {
|
||||||
|
resolveRequest(automationsResponse([]));
|
||||||
|
await pendingRequest;
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
import { SettingsView } from "@/components/settings/SettingsView";
|
import { SettingsView } from "@/components/settings/SettingsView";
|
||||||
@ -439,6 +439,42 @@ describe("SettingsView Apps catalog", () => {
|
|||||||
expect(screen.queryByText("Settings")).not.toBeInTheDocument();
|
expect(screen.queryByText("Settings")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("coalesces focus refreshes while automations are already loading", async () => {
|
||||||
|
let resolveAutomations!: (response: Response) => void;
|
||||||
|
const pendingAutomations = new Promise<Response>((resolve) => {
|
||||||
|
resolveAutomations = resolve;
|
||||||
|
});
|
||||||
|
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url === "/api/settings") return jsonResponse(settingsPayload());
|
||||||
|
if (url === "/api/webui/automations") return pendingAutomations;
|
||||||
|
return jsonResponse({});
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
renderSettingsView({
|
||||||
|
initialSection: "automations",
|
||||||
|
initialSettings: settingsPayload(),
|
||||||
|
showSidebar: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(fetchMock.mock.calls.filter(([input]) => (
|
||||||
|
String(input) === "/api/webui/automations"
|
||||||
|
))).toHaveLength(1);
|
||||||
|
});
|
||||||
|
window.dispatchEvent(new Event("focus"));
|
||||||
|
window.dispatchEvent(new Event("focus"));
|
||||||
|
|
||||||
|
expect(fetchMock.mock.calls.filter(([input]) => (
|
||||||
|
String(input) === "/api/webui/automations"
|
||||||
|
))).toHaveLength(1);
|
||||||
|
await act(async () => {
|
||||||
|
resolveAutomations(jsonResponse({ jobs: [] }));
|
||||||
|
await pendingAutomations;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("starts the managed API server from System", async () => {
|
it("starts the managed API server from System", async () => {
|
||||||
const base = settingsPayload();
|
const base = settingsPayload();
|
||||||
const stopped = {
|
const stopped = {
|
||||||
@ -468,7 +504,9 @@ describe("SettingsView Apps catalog", () => {
|
|||||||
|
|
||||||
renderSettingsView({ initialSection: "runtime", initialSettings: base, showSidebar: true });
|
renderSettingsView({ initialSection: "runtime", initialSettings: base, showSidebar: true });
|
||||||
|
|
||||||
fireEvent.click(await screen.findByRole("button", { name: "Start API server" }));
|
const startButton = await screen.findByRole("button", { name: "Start API server" });
|
||||||
|
await waitFor(() => expect(startButton).toBeEnabled());
|
||||||
|
fireEvent.click(startButton);
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(fetchMock).toHaveBeenCalledWith(
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
@ -1968,6 +2006,53 @@ describe("SettingsView Apps catalog", () => {
|
|||||||
expect(screen.queryByText("Peak tokens")).not.toBeInTheDocument();
|
expect(screen.queryByText("Peak tokens")).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("coalesces focus refreshes while usage is already loading", async () => {
|
||||||
|
const payload: SettingsPayload = {
|
||||||
|
...settingsPayload(),
|
||||||
|
usage: {
|
||||||
|
days: [],
|
||||||
|
total_tokens: 0,
|
||||||
|
total_tokens_30d: 0,
|
||||||
|
total_tokens_365d: 0,
|
||||||
|
peak_day_tokens: 0,
|
||||||
|
current_streak_days: 0,
|
||||||
|
longest_streak_days: 0,
|
||||||
|
active_days_30d: 0,
|
||||||
|
requests_30d: 0,
|
||||||
|
updated_at: null,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let resolveUsage!: (response: Response) => void;
|
||||||
|
const pendingUsage = new Promise<Response>((resolve) => {
|
||||||
|
resolveUsage = resolve;
|
||||||
|
});
|
||||||
|
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
const url = String(input);
|
||||||
|
if (url === "/api/settings") return jsonResponse(payload);
|
||||||
|
if (url === "/api/settings/usage") return pendingUsage;
|
||||||
|
return jsonResponse({});
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
|
||||||
|
renderSettingsView({ initialSection: "overview", initialSettings: payload });
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(fetchMock.mock.calls.filter(([input]) => (
|
||||||
|
String(input) === "/api/settings/usage"
|
||||||
|
))).toHaveLength(1);
|
||||||
|
});
|
||||||
|
window.dispatchEvent(new Event("focus"));
|
||||||
|
window.dispatchEvent(new Event("focus"));
|
||||||
|
|
||||||
|
expect(fetchMock.mock.calls.filter(([input]) => (
|
||||||
|
String(input) === "/api/settings/usage"
|
||||||
|
))).toHaveLength(1);
|
||||||
|
await act(async () => {
|
||||||
|
resolveUsage(jsonResponse(payload.usage));
|
||||||
|
await pendingUsage;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("aligns token activity days with the configured timezone", async () => {
|
it("aligns token activity days with the configured timezone", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
vi.setSystemTime(new Date("2026-06-02T18:00:00Z"));
|
vi.setSystemTime(new Date("2026-06-02T18:00:00Z"));
|
||||||
|
|||||||
128
webui/src/tests/skills-marketplace.test.tsx
Normal file
128
webui/src/tests/skills-marketplace.test.tsx
Normal file
@ -0,0 +1,128 @@
|
|||||||
|
import { act, fireEvent, render, renderHook, screen } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { SkillsMarketplace } from "@/components/settings/SkillsMarketplace";
|
||||||
|
import {
|
||||||
|
fetchSkills,
|
||||||
|
fetchTrendingMarketplaceSkills,
|
||||||
|
searchMarketplaceSkills,
|
||||||
|
} from "@/lib/api";
|
||||||
|
import type { NanobotClient } from "@/lib/nanobot-client";
|
||||||
|
import { SKILLS_CHANGED_EVENT } from "@/lib/skill-events";
|
||||||
|
import { ClientProvider } from "@/providers/ClientProvider";
|
||||||
|
import { useSkills } from "@/hooks/useSkills";
|
||||||
|
|
||||||
|
vi.mock("@/lib/api", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("@/lib/api")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
fetchSkills: vi.fn(),
|
||||||
|
fetchTrendingMarketplaceSkills: vi.fn(),
|
||||||
|
searchMarketplaceSkills: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = {} as NanobotClient;
|
||||||
|
|
||||||
|
function marketplace(token: string) {
|
||||||
|
return (
|
||||||
|
<ClientProvider client={client} token={token}>
|
||||||
|
<SkillsMarketplace
|
||||||
|
installedSkills={[]}
|
||||||
|
installing=""
|
||||||
|
onInstallingChange={() => {}}
|
||||||
|
/>
|
||||||
|
</ClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useSkills", () => {
|
||||||
|
it("does not let an older request overwrite a newer skill event", async () => {
|
||||||
|
let resolveSkills!: (value: Awaited<ReturnType<typeof fetchSkills>>) => void;
|
||||||
|
vi.mocked(fetchSkills).mockReset().mockImplementationOnce(
|
||||||
|
() => new Promise((resolve) => {
|
||||||
|
resolveSkills = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const installed = {
|
||||||
|
name: "react-testing",
|
||||||
|
description: "Test React apps.",
|
||||||
|
source: "workspace",
|
||||||
|
available: true,
|
||||||
|
};
|
||||||
|
const getToken = () => "tok";
|
||||||
|
const { result } = renderHook(() => useSkills(getToken));
|
||||||
|
|
||||||
|
expect(fetchSkills).toHaveBeenCalledTimes(1);
|
||||||
|
act(() => {
|
||||||
|
window.dispatchEvent(new CustomEvent(SKILLS_CHANGED_EVENT, {
|
||||||
|
detail: { skills: [installed] },
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
expect(result.current).toEqual([installed]);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
resolveSkills({ skills: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.current).toEqual([installed]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("SkillsMarketplace", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.mocked(fetchTrendingMarketplaceSkills).mockReset().mockResolvedValue({
|
||||||
|
period: "mixed",
|
||||||
|
provider: "all",
|
||||||
|
install_supported: true,
|
||||||
|
skills: [],
|
||||||
|
});
|
||||||
|
vi.mocked(searchMarketplaceSkills).mockReset().mockImplementation(
|
||||||
|
async (_token, query) => ({
|
||||||
|
query,
|
||||||
|
provider: "all",
|
||||||
|
install_supported: true,
|
||||||
|
skills: [],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps loaded marketplace data stable when the auth token rotates", async () => {
|
||||||
|
const { rerender } = render(marketplace("tok-old"));
|
||||||
|
|
||||||
|
await act(async () => {});
|
||||||
|
expect(fetchTrendingMarketplaceSkills).toHaveBeenCalledTimes(1);
|
||||||
|
expect(fetchTrendingMarketplaceSkills).toHaveBeenCalledWith("tok-old");
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByRole("textbox", { name: "Search skills" }), {
|
||||||
|
target: { value: "React" },
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(300);
|
||||||
|
});
|
||||||
|
expect(searchMarketplaceSkills).toHaveBeenCalledTimes(1);
|
||||||
|
expect(searchMarketplaceSkills).toHaveBeenLastCalledWith("tok-old", "React");
|
||||||
|
|
||||||
|
rerender(marketplace("tok-new"));
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(300);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fetchTrendingMarketplaceSkills).toHaveBeenCalledTimes(1);
|
||||||
|
expect(searchMarketplaceSkills).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByRole("textbox", { name: "Search skills" }), {
|
||||||
|
target: { value: "Vue" },
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
await vi.advanceTimersByTimeAsync(300);
|
||||||
|
});
|
||||||
|
expect(searchMarketplaceSkills).toHaveBeenCalledTimes(2);
|
||||||
|
expect(searchMarketplaceSkills).toHaveBeenLastCalledWith("tok-new", "Vue");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -180,11 +180,16 @@ function makeClient() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function wrap(client: ReturnType<typeof makeClient>, children: ReactNode, modelName?: string | null) {
|
function wrap(
|
||||||
|
client: ReturnType<typeof makeClient>,
|
||||||
|
children: ReactNode,
|
||||||
|
modelName?: string | null,
|
||||||
|
token = "tok",
|
||||||
|
) {
|
||||||
return (
|
return (
|
||||||
<ClientProvider
|
<ClientProvider
|
||||||
client={client as unknown as import("@/lib/nanobot-client").NanobotClient}
|
client={client as unknown as import("@/lib/nanobot-client").NanobotClient}
|
||||||
token="tok"
|
token={token}
|
||||||
modelName={modelName ?? null}
|
modelName={modelName ?? null}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
@ -241,6 +246,26 @@ function httpJson(body: unknown) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setDocumentVisibility(value: DocumentVisibilityState): void {
|
||||||
|
Object.defineProperty(document, "visibilityState", {
|
||||||
|
configurable: true,
|
||||||
|
value,
|
||||||
|
});
|
||||||
|
document.dispatchEvent(new Event("visibilitychange"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreDocumentVisibility(
|
||||||
|
descriptor: PropertyDescriptor | undefined,
|
||||||
|
): void {
|
||||||
|
if (descriptor) {
|
||||||
|
Object.defineProperty(document, "visibilityState", descriptor);
|
||||||
|
} else {
|
||||||
|
delete (document as Document & {
|
||||||
|
visibilityState?: DocumentVisibilityState;
|
||||||
|
}).visibilityState;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface ThreadResizeObserverInstance {
|
interface ThreadResizeObserverInstance {
|
||||||
elements: Element[];
|
elements: Element[];
|
||||||
callback: ResizeObserverCallback;
|
callback: ResizeObserverCallback;
|
||||||
@ -1758,7 +1783,7 @@ describe("ThreadShell", () => {
|
|||||||
expect(screen.getByText("row from the expired latest window")).toBeInTheDocument(),
|
expect(screen.getByText("row from the expired latest window")).toBeInTheDocument(),
|
||||||
);
|
);
|
||||||
|
|
||||||
act(() => document.dispatchEvent(new Event("visibilitychange")));
|
act(() => client._emitSessionUpdate("window-reset-chat", "thread"));
|
||||||
|
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(screen.getByText("answer in the new latest window")).toBeInTheDocument(),
|
expect(screen.getByText("answer in the new latest window")).toBeInTheDocument(),
|
||||||
@ -1776,7 +1801,7 @@ describe("ThreadShell", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("recovers an uncommitted reset lineage on the next foreground hydrate", async () => {
|
it("recovers an uncommitted reset lineage on the next canonical hydrate", async () => {
|
||||||
const client = makeClient();
|
const client = makeClient();
|
||||||
let chatACalls = 0;
|
let chatACalls = 0;
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
@ -1826,7 +1851,7 @@ describe("ThreadShell", () => {
|
|||||||
expect(screen.getByText("committed old lineage")).toBeInTheDocument();
|
expect(screen.getByText("committed old lineage")).toBeInTheDocument();
|
||||||
expect(screen.queryByText("disjoint new lineage")).not.toBeInTheDocument();
|
expect(screen.queryByText("disjoint new lineage")).not.toBeInTheDocument();
|
||||||
|
|
||||||
act(() => document.dispatchEvent(new Event("visibilitychange")));
|
act(() => client._emitSessionUpdate("lineage-chat-a", "thread"));
|
||||||
|
|
||||||
await waitFor(() => expect(chatACalls).toBe(3));
|
await waitFor(() => expect(chatACalls).toBe(3));
|
||||||
await waitFor(() => expect(screen.getByText("disjoint new lineage")).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText("disjoint new lineage")).toBeInTheDocument());
|
||||||
@ -1874,7 +1899,7 @@ describe("ThreadShell", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByText("old canonical row")).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText("old canonical row")).toBeInTheDocument());
|
||||||
act(() => document.dispatchEvent(new Event("visibilitychange")));
|
act(() => client._emitSessionUpdate("reset-tail-race", "thread"));
|
||||||
await waitFor(() => expect(historyCalls).toBe(2));
|
await waitFor(() => expect(historyCalls).toBe(2));
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
@ -1938,7 +1963,7 @@ describe("ThreadShell", () => {
|
|||||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||||
await waitFor(() => expect(screen.getByText("rejected local turn")).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText("rejected local turn")).toBeInTheDocument());
|
||||||
|
|
||||||
act(() => document.dispatchEvent(new Event("visibilitychange")));
|
act(() => client._emitSessionUpdate("empty-reset-chat", "thread"));
|
||||||
|
|
||||||
await waitFor(() => expect(historyCalls).toBe(2));
|
await waitFor(() => expect(historyCalls).toBe(2));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
@ -2023,7 +2048,7 @@ describe("ThreadShell", () => {
|
|||||||
});
|
});
|
||||||
canonicalComplete = true;
|
canonicalComplete = true;
|
||||||
|
|
||||||
act(() => document.dispatchEvent(new Event("visibilitychange")));
|
act(() => client._emitSessionUpdate("strict-canonical", "thread"));
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByText("strict canonical answer")).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText("strict canonical answer")).toBeInTheDocument());
|
||||||
expect(client.reconcileCanonicalCompletion).toHaveBeenCalledTimes(1);
|
expect(client.reconcileCanonicalCompletion).toHaveBeenCalledTimes(1);
|
||||||
@ -2094,7 +2119,7 @@ describe("ThreadShell", () => {
|
|||||||
.mockImplementationOnce(() => false)
|
.mockImplementationOnce(() => false)
|
||||||
.mockImplementation((...args) => reconcileAfterReject?.(...args) ?? false);
|
.mockImplementation((...args) => reconcileAfterReject?.(...args) ?? false);
|
||||||
|
|
||||||
act(() => document.dispatchEvent(new Event("visibilitychange")));
|
act(() => client._emitSessionUpdate("layout-recheck", "thread"));
|
||||||
|
|
||||||
await waitFor(() => expect(historyCalls).toBe(2));
|
await waitFor(() => expect(historyCalls).toBe(2));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
@ -2104,7 +2129,7 @@ describe("ThreadShell", () => {
|
|||||||
expect(screen.queryByText("layout canonical answer")).not.toBeInTheDocument();
|
expect(screen.queryByText("layout canonical answer")).not.toBeInTheDocument();
|
||||||
expect(screen.getByRole("button", { name: "Stop response" })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: "Stop response" })).toBeInTheDocument();
|
||||||
|
|
||||||
act(() => document.dispatchEvent(new Event("visibilitychange")));
|
act(() => client._emitSessionUpdate("layout-recheck", "thread"));
|
||||||
|
|
||||||
await waitFor(() => expect(historyCalls).toBe(3));
|
await waitFor(() => expect(historyCalls).toBe(3));
|
||||||
await waitFor(() => expect(screen.getByText("layout canonical answer")).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText("layout canonical answer")).toBeInTheDocument());
|
||||||
@ -2258,7 +2283,7 @@ describe("ThreadShell", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByText("old answer")).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText("old answer")).toBeInTheDocument());
|
||||||
act(() => document.dispatchEvent(new Event("visibilitychange")));
|
act(() => client._emitSessionUpdate("run-generation-chat", "thread"));
|
||||||
await waitFor(() => expect(historyCalls).toBe(2));
|
await waitFor(() => expect(historyCalls).toBe(2));
|
||||||
|
|
||||||
const newTurnId = "turn-started-during-refresh";
|
const newTurnId = "turn-started-during-refresh";
|
||||||
@ -2351,7 +2376,7 @@ describe("ThreadShell", () => {
|
|||||||
});
|
});
|
||||||
await waitFor(() => expect(screen.getByText("partial")).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText("partial")).toBeInTheDocument());
|
||||||
|
|
||||||
act(() => document.dispatchEvent(new Event("visibilitychange")));
|
act(() => client._emitSessionUpdate("late-frame-chat", "thread"));
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(screen.getByText("canonical complete answer")).toBeInTheDocument(),
|
expect(screen.getByText("canonical complete answer")).toBeInTheDocument(),
|
||||||
);
|
);
|
||||||
@ -2435,7 +2460,7 @@ describe("ThreadShell", () => {
|
|||||||
});
|
});
|
||||||
expect(screen.getByRole("button", { name: "Stop response" })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: "Stop response" })).toBeInTheDocument();
|
||||||
|
|
||||||
act(() => document.dispatchEvent(new Event("visibilitychange")));
|
act(() => client._emitSessionUpdate("visibility-complete-a", "thread"));
|
||||||
await waitFor(() => expect(screen.getByText("completed while hidden")).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText("completed while hidden")).toBeInTheDocument());
|
||||||
expect(screen.queryByRole("button", { name: "Stop response" })).not.toBeInTheDocument();
|
expect(screen.queryByRole("button", { name: "Stop response" })).not.toBeInTheDocument();
|
||||||
expect(client.getRunStartedAt("visibility-complete-a")).toBeNull();
|
expect(client.getRunStartedAt("visibility-complete-a")).toBeNull();
|
||||||
@ -2495,7 +2520,7 @@ describe("ThreadShell", () => {
|
|||||||
});
|
});
|
||||||
expect(screen.getByRole("button", { name: "Stop response" })).toBeInTheDocument();
|
expect(screen.getByRole("button", { name: "Stop response" })).toBeInTheDocument();
|
||||||
|
|
||||||
act(() => document.dispatchEvent(new Event("visibilitychange")));
|
act(() => client._emitSessionUpdate("empty-answer", "thread"));
|
||||||
|
|
||||||
await waitFor(() => expect(historyCalls).toBe(2));
|
await waitFor(() => expect(historyCalls).toBe(2));
|
||||||
await waitFor(() => expect(client.reconcileCanonicalCompletion).toHaveBeenCalledWith(
|
await waitFor(() => expect(client.reconcileCanonicalCompletion).toHaveBeenCalledWith(
|
||||||
@ -2717,6 +2742,7 @@ describe("ThreadShell", () => {
|
|||||||
it("refreshes the current thread when the page returns to the foreground", async () => {
|
it("refreshes the current thread when the page returns to the foreground", async () => {
|
||||||
const client = makeClient();
|
const client = makeClient();
|
||||||
let historyCalls = 0;
|
let historyCalls = 0;
|
||||||
|
const turnId = "turn-visible-chat";
|
||||||
const visibilityDescriptor = Object.getOwnPropertyDescriptor(document, "visibilityState");
|
const visibilityDescriptor = Object.getOwnPropertyDescriptor(document, "visibilityState");
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
"fetch",
|
"fetch",
|
||||||
@ -2724,16 +2750,22 @@ describe("ThreadShell", () => {
|
|||||||
const url = String(input);
|
const url = String(input);
|
||||||
if (url.includes("websocket%3Avisible-chat/webui-thread")) {
|
if (url.includes("websocket%3Avisible-chat/webui-thread")) {
|
||||||
historyCalls += 1;
|
historyCalls += 1;
|
||||||
return httpJson(
|
return httpJson({
|
||||||
transcriptFromSimpleMessages(
|
...transcriptFromSimpleMessages(
|
||||||
historyCalls === 1
|
historyCalls === 1
|
||||||
? [{ role: "user", content: "question" }]
|
? [{ role: "user", content: "question", turnId }]
|
||||||
: [
|
: [
|
||||||
{ role: "user", content: "question" },
|
{ role: "user", content: "question", turnId },
|
||||||
{ role: "assistant", content: "answer completed in background" },
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: "answer completed in background",
|
||||||
|
turnId,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
has_pending_tool_calls: historyCalls === 1,
|
||||||
|
completed_turn_ids: historyCalls === 1 ? [] : [turnId],
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
@ -2757,22 +2789,23 @@ describe("ThreadShell", () => {
|
|||||||
);
|
);
|
||||||
await waitFor(() => expect(screen.getByText("question")).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText("question")).toBeInTheDocument());
|
||||||
expect(historyCalls).toBe(1);
|
expect(historyCalls).toBe(1);
|
||||||
|
act(() => {
|
||||||
|
client._emitChat("visible-chat", {
|
||||||
|
event: "goal_status",
|
||||||
|
chat_id: "visible-chat",
|
||||||
|
status: "running",
|
||||||
|
started_at: 6_000,
|
||||||
|
turn_id: turnId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
Object.defineProperty(document, "visibilityState", {
|
setDocumentVisibility("hidden");
|
||||||
configurable: true,
|
|
||||||
value: "hidden",
|
|
||||||
});
|
|
||||||
document.dispatchEvent(new Event("visibilitychange"));
|
|
||||||
});
|
});
|
||||||
expect(historyCalls).toBe(1);
|
expect(historyCalls).toBe(1);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
Object.defineProperty(document, "visibilityState", {
|
setDocumentVisibility("visible");
|
||||||
configurable: true,
|
|
||||||
value: "visible",
|
|
||||||
});
|
|
||||||
document.dispatchEvent(new Event("visibilitychange"));
|
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -2781,11 +2814,114 @@ describe("ThreadShell", () => {
|
|||||||
expect(screen.getByText("answer completed in background")).toBeInTheDocument(),
|
expect(screen.getByText("answer completed in background")).toBeInTheDocument(),
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
if (visibilityDescriptor) {
|
restoreDocumentVisibility(visibilityDescriptor);
|
||||||
Object.defineProperty(document, "visibilityState", visibilityDescriptor);
|
}
|
||||||
} else {
|
});
|
||||||
delete (document as Document & { visibilityState?: DocumentVisibilityState }).visibilityState;
|
|
||||||
}
|
it("does not refresh an idle thread for visibility notifications", async () => {
|
||||||
|
const client = makeClient();
|
||||||
|
let historyCalls = 0;
|
||||||
|
const visibilityDescriptor = Object.getOwnPropertyDescriptor(document, "visibilityState");
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
if (String(input).includes("websocket%3Aidle-visible-chat/webui-thread")) {
|
||||||
|
historyCalls += 1;
|
||||||
|
return httpJson(transcriptFromSimpleMessages([
|
||||||
|
{ role: "assistant", content: "settled answer" },
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
json: async () => ({}),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
render(
|
||||||
|
wrap(
|
||||||
|
client,
|
||||||
|
<ThreadShell
|
||||||
|
session={session("idle-visible-chat")}
|
||||||
|
title="Idle visible chat"
|
||||||
|
onToggleSidebar={() => {}}
|
||||||
|
onNewChat={() => {}}
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(screen.getByText("settled answer")).toBeInTheDocument());
|
||||||
|
|
||||||
|
act(() => document.dispatchEvent(new Event("visibilitychange")));
|
||||||
|
act(() => {
|
||||||
|
setDocumentVisibility("hidden");
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
setDocumentVisibility("visible");
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(historyCalls).toBe(1);
|
||||||
|
} finally {
|
||||||
|
restoreDocumentVisibility(visibilityDescriptor);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries a failed hydration when the page returns to the foreground", async () => {
|
||||||
|
const client = makeClient();
|
||||||
|
let historyCalls = 0;
|
||||||
|
const visibilityDescriptor = Object.getOwnPropertyDescriptor(document, "visibilityState");
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
if (String(input).includes("websocket%3Aretry-visible-chat/webui-thread")) {
|
||||||
|
historyCalls += 1;
|
||||||
|
if (historyCalls === 1) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
json: async () => ({}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return httpJson(transcriptFromSimpleMessages([
|
||||||
|
{ role: "assistant", content: "recovered answer" },
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
json: async () => ({}),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
render(
|
||||||
|
wrap(
|
||||||
|
client,
|
||||||
|
<ThreadShell
|
||||||
|
session={session("retry-visible-chat")}
|
||||||
|
title="Retry visible chat"
|
||||||
|
onToggleSidebar={() => {}}
|
||||||
|
onNewChat={() => {}}
|
||||||
|
/>,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(historyCalls).toBe(1));
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
setDocumentVisibility("hidden");
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
setDocumentVisibility("visible");
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(historyCalls).toBe(2));
|
||||||
|
expect(await screen.findByText("recovered answer")).toBeInTheDocument();
|
||||||
|
} finally {
|
||||||
|
restoreDocumentVisibility(visibilityDescriptor);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -2847,7 +2983,7 @@ describe("ThreadShell", () => {
|
|||||||
expect(historyCalls).toBe(1);
|
expect(historyCalls).toBe(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not refetch thread history for metadata-only session updates", async () => {
|
it("keeps rendered media mounted for metadata-only session updates", async () => {
|
||||||
const client = makeClient();
|
const client = makeClient();
|
||||||
let historyCalls = 0;
|
let historyCalls = 0;
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
@ -2856,12 +2992,16 @@ describe("ThreadShell", () => {
|
|||||||
const url = String(input);
|
const url = String(input);
|
||||||
if (url.includes("websocket%3Achat-a/webui-thread")) {
|
if (url.includes("websocket%3Achat-a/webui-thread")) {
|
||||||
historyCalls += 1;
|
historyCalls += 1;
|
||||||
return httpJson(
|
const thread = transcriptFromSimpleMessages([
|
||||||
transcriptFromSimpleMessages([
|
{ role: "user", content: "question" },
|
||||||
{ role: "user", content: "question" },
|
{ role: "assistant", content: "answer" },
|
||||||
{ role: "assistant", content: "answer" },
|
]);
|
||||||
]),
|
thread.messages[1]!.media = [{
|
||||||
);
|
kind: "image",
|
||||||
|
url: "/api/media/stable/image",
|
||||||
|
name: "answer.png",
|
||||||
|
}];
|
||||||
|
return httpJson(thread);
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
@ -2884,6 +3024,7 @@ describe("ThreadShell", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await waitFor(() => expect(screen.getByText("answer")).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText("answer")).toBeInTheDocument());
|
||||||
|
const image = screen.getByRole("img", { name: "answer.png" });
|
||||||
expect(historyCalls).toBe(1);
|
expect(historyCalls).toBe(1);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@ -2891,6 +3032,56 @@ describe("ThreadShell", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(historyCalls).toBe(1);
|
expect(historyCalls).toBe(1);
|
||||||
|
expect(screen.getByRole("img", { name: "answer.png" })).toBe(image);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps rendered media mounted when the auth token rotates", async () => {
|
||||||
|
const client = makeClient();
|
||||||
|
let historyCalls = 0;
|
||||||
|
vi.stubGlobal(
|
||||||
|
"fetch",
|
||||||
|
vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
if (String(input).includes("websocket%3Atoken-media/webui-thread")) {
|
||||||
|
historyCalls += 1;
|
||||||
|
const thread = transcriptFromSimpleMessages([
|
||||||
|
{ role: "user", content: "question" },
|
||||||
|
{ role: "assistant", content: "answer" },
|
||||||
|
]);
|
||||||
|
thread.messages[1]!.media = [{
|
||||||
|
kind: "image",
|
||||||
|
url: "/api/media/stable/token-image",
|
||||||
|
name: "token-answer.png",
|
||||||
|
}];
|
||||||
|
return httpJson(thread);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
json: async () => ({}),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const view = (token: string) => wrap(
|
||||||
|
client,
|
||||||
|
<ThreadShell
|
||||||
|
session={session("token-media")}
|
||||||
|
title="Token media"
|
||||||
|
onToggleSidebar={() => {}}
|
||||||
|
onNewChat={() => {}}
|
||||||
|
/>,
|
||||||
|
null,
|
||||||
|
token,
|
||||||
|
);
|
||||||
|
const { rerender } = render(view("tok-old"));
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText("answer")).toBeInTheDocument());
|
||||||
|
const image = screen.getByRole("img", { name: "token-answer.png" });
|
||||||
|
|
||||||
|
rerender(view("tok-new"));
|
||||||
|
await act(async () => Promise.resolve());
|
||||||
|
|
||||||
|
expect(historyCalls).toBe(1);
|
||||||
|
expect(screen.getByRole("img", { name: "token-answer.png" })).toBe(image);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not scroll again when canonical history refreshes after a session update", async () => {
|
it("does not scroll again when canonical history refreshes after a session update", async () => {
|
||||||
@ -3454,6 +3645,68 @@ describe("ThreadShell", () => {
|
|||||||
expect(screen.getByRole("option", { name: /@gimp/i })).toBeInTheDocument();
|
expect(screen.getByRole("option", { name: /@gimp/i })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not let an older catalog request overwrite a newer install event", async () => {
|
||||||
|
const client = makeClient();
|
||||||
|
let resolveCatalog!: (response: Response) => void;
|
||||||
|
const pendingCatalog = new Promise<Response>((resolve) => {
|
||||||
|
resolveCatalog = resolve;
|
||||||
|
});
|
||||||
|
vi.mocked(fetch).mockImplementation((input) => {
|
||||||
|
if (String(input).includes("/api/settings/cli-apps?installed_only=1")) {
|
||||||
|
return pendingCatalog;
|
||||||
|
}
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: false,
|
||||||
|
status: 404,
|
||||||
|
json: async () => ({}),
|
||||||
|
} as Response);
|
||||||
|
});
|
||||||
|
render(wrap(
|
||||||
|
client,
|
||||||
|
<ThreadShell
|
||||||
|
session={session("chat-cli-race")}
|
||||||
|
title="Chat chat-cli-race"
|
||||||
|
onToggleSidebar={() => {}}
|
||||||
|
onGoHome={() => {}}
|
||||||
|
onNewChat={() => {}}
|
||||||
|
/>,
|
||||||
|
));
|
||||||
|
|
||||||
|
const input = await screen.findByLabelText("Message input");
|
||||||
|
await waitFor(() => expect(fetch).toHaveBeenCalledWith(
|
||||||
|
"/api/settings/cli-apps?installed_only=1",
|
||||||
|
expect.anything(),
|
||||||
|
));
|
||||||
|
const payload: CliAppsPayload = {
|
||||||
|
apps: [{
|
||||||
|
name: "gimp",
|
||||||
|
display_name: "GIMP",
|
||||||
|
category: "image",
|
||||||
|
description: "Image editing",
|
||||||
|
requires: "",
|
||||||
|
source: "harness",
|
||||||
|
entry_point: "cli-anything-gimp",
|
||||||
|
install_supported: true,
|
||||||
|
installed: true,
|
||||||
|
available: true,
|
||||||
|
status: "installed",
|
||||||
|
logo_url: null,
|
||||||
|
brand_color: "#5C5543",
|
||||||
|
skill_installed: true,
|
||||||
|
}],
|
||||||
|
installed_count: 1,
|
||||||
|
catalog_updated_at: "2026-07-30",
|
||||||
|
};
|
||||||
|
await act(async () => {
|
||||||
|
window.dispatchEvent(new CustomEvent(CLI_APPS_CHANGED_EVENT, { detail: payload }));
|
||||||
|
resolveCatalog(httpJson({ apps: [], installed_count: 0 }));
|
||||||
|
await pendingCatalog;
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.change(input, { target: { value: "@", selectionStart: 1 } });
|
||||||
|
expect(screen.getByRole("option", { name: /@gimp/i })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps installed app mentions available during transient catalog refresh failures", async () => {
|
it("keeps installed app mentions available during transient catalog refresh failures", async () => {
|
||||||
const client = makeClient();
|
const client = makeClient();
|
||||||
const payload: CliAppsPayload = {
|
const payload: CliAppsPayload = {
|
||||||
|
|||||||
@ -42,12 +42,16 @@ function fakeClient() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function wrap(client: ReturnType<typeof fakeClient>) {
|
function wrap(
|
||||||
|
client: ReturnType<typeof fakeClient>,
|
||||||
|
tokenSource: string | { current: string } = "tok",
|
||||||
|
) {
|
||||||
return function Wrapper({ children }: { children: ReactNode }) {
|
return function Wrapper({ children }: { children: ReactNode }) {
|
||||||
|
const token = typeof tokenSource === "string" ? tokenSource : tokenSource.current;
|
||||||
return (
|
return (
|
||||||
<ClientProvider
|
<ClientProvider
|
||||||
client={client as unknown as import("@/lib/nanobot-client").NanobotClient}
|
client={client as unknown as import("@/lib/nanobot-client").NanobotClient}
|
||||||
token="tok"
|
token={token}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</ClientProvider>
|
</ClientProvider>
|
||||||
@ -190,6 +194,76 @@ describe("useSessions", () => {
|
|||||||
expect(api.listSessions).toHaveBeenCalledTimes(2);
|
expect(api.listSessions).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("coalesces a same-task burst of session updates into one refresh", async () => {
|
||||||
|
vi.mocked(api.listSessions).mockResolvedValue([]);
|
||||||
|
const client = fakeClient();
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useSessions(), {
|
||||||
|
wrapper: wrap(client),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
expect(api.listSessions).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
client.emitSessionUpdate("chat-a", "metadata");
|
||||||
|
client.emitSessionUpdate("chat-a", "thread");
|
||||||
|
client.emitSessionUpdate("chat-b", "metadata");
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
expect(api.listSessions).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("runs one trailing refresh when an update arrives during a session request", async () => {
|
||||||
|
let resolveInFlight!: (rows: []) => void;
|
||||||
|
vi.mocked(api.listSessions)
|
||||||
|
.mockResolvedValueOnce([])
|
||||||
|
.mockImplementationOnce(() => new Promise((resolve) => {
|
||||||
|
resolveInFlight = resolve;
|
||||||
|
}))
|
||||||
|
.mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
key: "websocket:chat-a",
|
||||||
|
channel: "websocket",
|
||||||
|
chatId: "chat-a",
|
||||||
|
createdAt: "2026-04-16T10:00:00Z",
|
||||||
|
updatedAt: "2026-04-16T10:01:00Z",
|
||||||
|
title: "Latest title",
|
||||||
|
preview: "Latest preview",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const client = fakeClient();
|
||||||
|
|
||||||
|
const { result } = renderHook(() => useSessions(), {
|
||||||
|
wrapper: wrap(client),
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
client.emitSessionUpdate("chat-a", "metadata");
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(api.listSessions).toHaveBeenCalledTimes(2));
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
client.emitSessionUpdate("chat-a", "thread");
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
expect(api.listSessions).toHaveBeenCalledTimes(2);
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
resolveInFlight([]);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(api.listSessions).toHaveBeenCalledTimes(3));
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
expect(result.current.sessions[0]?.title).toBe("Latest title");
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps a newly created chat visible until the server session list catches up", async () => {
|
it("keeps a newly created chat visible until the server session list catches up", async () => {
|
||||||
vi.mocked(api.listSessions)
|
vi.mocked(api.listSessions)
|
||||||
.mockResolvedValueOnce([])
|
.mockResolvedValueOnce([])
|
||||||
@ -506,6 +580,73 @@ describe("useSessions", () => {
|
|||||||
expect(result.current.hasPendingToolCalls).toBe(false);
|
expect(result.current.hasPendingToolCalls).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not reload history when only the auth token rotates", async () => {
|
||||||
|
const tokenSource = { current: "tok-old" };
|
||||||
|
vi.mocked(api.fetchWebuiThread).mockResolvedValue({
|
||||||
|
schemaVersion: 3,
|
||||||
|
messages: [
|
||||||
|
{ id: "a1", role: "assistant", content: "stable", createdAt: 1 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
() => useSessionHistory("websocket:token-rotation"),
|
||||||
|
{ wrapper: wrap(fakeClient(), tokenSource) },
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
expect(api.fetchWebuiThread).toHaveBeenCalledTimes(1);
|
||||||
|
expect(api.fetchWebuiThread).toHaveBeenLastCalledWith(
|
||||||
|
"tok-old",
|
||||||
|
"websocket:token-rotation",
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
|
||||||
|
tokenSource.current = "tok-new";
|
||||||
|
rerender();
|
||||||
|
await act(async () => Promise.resolve());
|
||||||
|
expect(api.fetchWebuiThread).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
act(() => result.current.refresh());
|
||||||
|
await waitFor(() => expect(api.fetchWebuiThread).toHaveBeenCalledTimes(2));
|
||||||
|
expect(api.fetchWebuiThread).toHaveBeenLastCalledWith(
|
||||||
|
"tok-new",
|
||||||
|
"websocket:token-rotation",
|
||||||
|
expect.any(Object),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("aborts a superseded latest-history request without surfacing an error", async () => {
|
||||||
|
let firstSignal: AbortSignal | undefined;
|
||||||
|
vi.mocked(api.fetchWebuiThread)
|
||||||
|
.mockImplementationOnce((_token, _key, optionsOrBase) => new Promise((_resolve, reject) => {
|
||||||
|
if (typeof optionsOrBase !== "string") firstSignal = optionsOrBase?.signal;
|
||||||
|
firstSignal?.addEventListener("abort", () => {
|
||||||
|
reject(new DOMException("Aborted", "AbortError"));
|
||||||
|
});
|
||||||
|
}))
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
schemaVersion: 3,
|
||||||
|
messages: [
|
||||||
|
{ id: "a2", role: "assistant", content: "latest", createdAt: 2 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { result } = renderHook(
|
||||||
|
() => useSessionHistory("websocket:superseded"),
|
||||||
|
{ wrapper: wrap(fakeClient()) },
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(firstSignal).toBeDefined());
|
||||||
|
act(() => result.current.refresh());
|
||||||
|
|
||||||
|
await waitFor(() => expect(api.fetchWebuiThread).toHaveBeenCalledTimes(2));
|
||||||
|
expect(firstSignal?.aborted).toBe(true);
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
expect(result.current.error).toBeNull();
|
||||||
|
expect(result.current.messages.map((message) => message.id)).toEqual(["a2"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("loads older transcript pages before the current history", async () => {
|
it("loads older transcript pages before the current history", async () => {
|
||||||
vi.mocked(api.fetchWebuiThread)
|
vi.mocked(api.fetchWebuiThread)
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
@ -540,10 +681,15 @@ describe("useSessions", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => expect(result.current.loading).toBe(false));
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
expect(api.fetchWebuiThread).toHaveBeenCalledWith("tok", "websocket:paged", {
|
expect(api.fetchWebuiThread).toHaveBeenCalledWith(
|
||||||
limit: 160,
|
"tok",
|
||||||
direction: "latest",
|
"websocket:paged",
|
||||||
});
|
expect.objectContaining({
|
||||||
|
limit: 160,
|
||||||
|
direction: "latest",
|
||||||
|
signal: expect.any(AbortSignal),
|
||||||
|
}),
|
||||||
|
);
|
||||||
expect(result.current.hasMoreBefore).toBe(true);
|
expect(result.current.hasMoreBefore).toBe(true);
|
||||||
expect(result.current.userMessageOffset).toBe(1);
|
expect(result.current.userMessageOffset).toBe(1);
|
||||||
const latestVersion = result.current.version;
|
const latestVersion = result.current.version;
|
||||||
@ -554,10 +700,15 @@ describe("useSessions", () => {
|
|||||||
await result.current.loadOlder();
|
await result.current.loadOlder();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(api.fetchWebuiThread).toHaveBeenLastCalledWith("tok", "websocket:paged", {
|
expect(api.fetchWebuiThread).toHaveBeenLastCalledWith(
|
||||||
limit: 120,
|
"tok",
|
||||||
before: "cursor-2",
|
"websocket:paged",
|
||||||
});
|
expect.objectContaining({
|
||||||
|
limit: 120,
|
||||||
|
before: "cursor-2",
|
||||||
|
signal: expect.any(AbortSignal),
|
||||||
|
}),
|
||||||
|
);
|
||||||
expect(result.current.messages.map((message) => message.content)).toEqual([
|
expect(result.current.messages.map((message) => message.content)).toEqual([
|
||||||
"old question",
|
"old question",
|
||||||
"old answer",
|
"old answer",
|
||||||
@ -571,6 +722,46 @@ describe("useSessions", () => {
|
|||||||
expect(result.current.continuity).toBe("initial");
|
expect(result.current.continuity).toBe("initial");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("aborts an older-history request when the consumer unmounts", async () => {
|
||||||
|
let olderSignal: AbortSignal | undefined;
|
||||||
|
vi.mocked(api.fetchWebuiThread)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
schemaVersion: 3,
|
||||||
|
messages: [
|
||||||
|
{ id: "u2", role: "user", content: "latest question", createdAt: 2 },
|
||||||
|
],
|
||||||
|
page: {
|
||||||
|
before_cursor: "cursor-2",
|
||||||
|
has_more_before: true,
|
||||||
|
loaded_message_count: 1,
|
||||||
|
user_message_offset: 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.mockImplementationOnce((_token, _key, optionsOrBase) => new Promise((_resolve, reject) => {
|
||||||
|
if (typeof optionsOrBase !== "string") olderSignal = optionsOrBase?.signal;
|
||||||
|
olderSignal?.addEventListener("abort", () => {
|
||||||
|
reject(new DOMException("Aborted", "AbortError"));
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { result, unmount } = renderHook(
|
||||||
|
() => useSessionHistory("websocket:unmount-older"),
|
||||||
|
{ wrapper: wrap(fakeClient()) },
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.loading).toBe(false));
|
||||||
|
let olderRequest!: Promise<void>;
|
||||||
|
act(() => {
|
||||||
|
olderRequest = result.current.loadOlder();
|
||||||
|
});
|
||||||
|
await waitFor(() => expect(olderSignal).toBeDefined());
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
|
||||||
|
expect(olderSignal?.aborted).toBe(true);
|
||||||
|
await expect(olderRequest).resolves.toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("preserves a loaded prefix when a canonical latest window overlaps its tail", async () => {
|
it("preserves a loaded prefix when a canonical latest window overlaps its tail", async () => {
|
||||||
vi.mocked(api.fetchWebuiThread)
|
vi.mocked(api.fetchWebuiThread)
|
||||||
.mockResolvedValueOnce({
|
.mockResolvedValueOnce({
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user