Merge pull request #2842 from arc53/Agent-menu-UI

Agent menu UI
This commit is contained in:
Pavel authored and GitHub committed 2026-09-28 17:20:26 +02:00
commit ac7bbd811f
223 files changed
+23053 -5733

No files matched your search

+4
View File
@@ -201,6 +201,9 @@ RULES: dict[tuple[str, str], Rule] = {
("/api/agents/<string:agent_id>/schedules", "GET"): _rule(
"schedules:read", refs=(("agents", (VIEW, "agent_id")),), blocked_by=_NON_AGENT_FAMILIES
),
("/api/agents/<string:agent_id>/schedules/stats", "GET"): _rule(
"schedules:read", refs=(("agents", (VIEW, "agent_id")),), blocked_by=_NON_AGENT_FAMILIES
),
("/api/agents/<string:agent_id>/schedules", "POST"): _rule(
"schedules:write", refs=(("agents", (VIEW, "agent_id")),), blocked_by=_NON_AGENT_FAMILIES
),
@@ -225,6 +228,7 @@ RULES: dict[tuple[str, str], Rule] = {
("/api/sources/<string:source_id>/graph/node/<string:node_id>", "GET"): _rule(
"sources:read", (VIEW, "source_id")
),
("/api/sources/<string:source_id>/graph/nodes", "GET"): _rule("sources:read", (VIEW, "source_id")),
# Ingestion and attachment extraction both report through this poll.
("/api/task_status", "GET"): _rule(any_of=("sources:read", "sources:write", "chat:run"), open=True),
("/api/upload", "POST"): _rule("sources:write"),
+49
View File
@@ -317,6 +317,55 @@ class AgentSchedules(Resource):
return _ok({"schedule": _format_schedule(created)}, status=201)
@schedules_ns.route("/agents/<string:agent_id>/schedules/stats")
class AgentScheduleStats(Resource):
@api.doc(
description="Run stats for an agent's schedules over a recent window.",
params={"days": "Window in days (default 30, clamped to 1..365)"},
)
@_safe_route
def get(self, agent_id: str):
"""Return run count, failures, tokens and latest failure for an agent.
Args:
agent_id: Agent id from the URL.
Returns:
A Flask response with ``days``, ``runs``, ``failed``, ``tokens``
and ``latest_failure`` (or an error envelope).
"""
user_id = _user_id()
if not user_id:
return _err("unauthorized", 401)
agent = _agent_owned(agent_id, user_id)
if agent is None:
return _err("agent not found", 404)
try:
days = max(1, min(int(request.args.get("days", 30)), 365))
except (TypeError, ValueError):
days = 30
try:
with db_readonly() as conn:
stats = ScheduleRunsRepository(conn).stats_for_agent(
str(agent["id"]), user_id, days=days,
)
except Exception as exc:
current_app.logger.error(
"schedule stats failed: %s", exc, exc_info=True,
)
return _err("internal error", 500)
latest = stats.get("latest_failure")
return _ok(
{
"days": days,
"runs": stats["runs"],
"failed": stats["failed"],
"tokens": stats["tokens"],
"latest_failure": _format_run(latest) if latest else None,
}
)
@schedules_ns.route("/schedules/<string:schedule_id>")
class ScheduleResource(Resource):
@api.doc(description="Get schedule by id.")
+85 -20
View File
@@ -7,10 +7,11 @@ from flask_restx import fields, Namespace, Resource
from docsgpt.api import api
from docsgpt.api.user.base import get_vector_store
from docsgpt.api.user.team_sharing import effective_write_owner
from docsgpt.api.user.team_sharing import can_access, effective_write_owner
from docsgpt.storage.db.repositories.sources import SourcesRepository
from docsgpt.storage.db.session import db_readonly
from docsgpt.utils import check_required_fields, num_tokens_from_string
from docsgpt.vectorstore.base import InvalidChunkMetadataError
sources_chunks_ns = Namespace(
"sources", description="Source document management operations", path="/api"
@@ -18,12 +19,18 @@ sources_chunks_ns = Namespace(
def _resolve_source(doc_id: str, user: str):
"""Resolve a source (UUID or legacy ObjectId) for the caller.
"""Resolve a source (UUID or legacy ObjectId) the caller may READ.
Returns the row dict (with PG UUID in ``id``) or ``None`` if missing.
Read access = owner or any team grant (viewer/editor). Returns the row
dict (with PG UUID in ``id``) or ``None`` if missing or not visible.
"""
with db_readonly() as conn:
return SourcesRepository(conn).get_any(doc_id, user)
doc = SourcesRepository(conn).get_any(doc_id, user)
if doc is not None:
return doc
if not can_access(conn, "source", doc_id, user):
return None
return SourcesRepository(conn).get_by_id(doc_id)
def _resolve_source_for_write(doc_id: str, user: str):
@@ -45,6 +52,35 @@ def _resolve_source_for_write(doc_id: str, user: str):
return SourcesRepository(conn).get_any(doc_id, owner)
def _remap_graph_chunk(doc: dict, old_chunk_id: str, new_chunk_id: str) -> None:
"""Move a graphrag source's links from an edited chunk's old id to its new one.
Only needed when the store's ``update_chunk`` fell back to re-adding the
chunk under a new id (stores that update in place keep the id). Without
this the graph keeps pointing at the deleted row: the entity loses the
chunk and retrieval stops returning it. A failure is logged, not raised,
because the edit itself has already been saved.
Args:
doc: The resolved source row.
old_chunk_id: The edited chunk's previous id.
new_chunk_id: The id the edit was saved under.
"""
from docsgpt.storage.db.source_config import SourceConfig
if SourceConfig.parse(doc.get("config")).kind != "graphrag":
return
try:
from docsgpt.graphrag.store import GraphStore
GraphStore().remap_chunk(str(doc["id"]), old_chunk_id, new_chunk_id)
except Exception as e:
current_app.logger.error(
f"Failed to remap graph links from chunk {old_chunk_id} to {new_chunk_id}: {e}",
exc_info=True,
)
def _has_usable_token_count(metadata: dict) -> bool:
"""Whether ``metadata`` already carries a count worth showing.
@@ -98,6 +134,38 @@ def _with_token_counts(chunks: list) -> list:
return chunks
def _path_ends_with(value: str, path: str) -> bool:
"""Return whether ``value`` is ``path`` or ends with it at a ``/`` boundary.
A bare ``endswith`` let a root ``setup.md`` also claim the chunks of
``guides/setup.md`` (and ``a.md`` those of ``data.md``).
"""
return bool(value) and (value == path or value.endswith(f"/{path}"))
def _chunk_matches_path(metadata: dict, path: str) -> bool:
"""Return whether a chunk belongs to the tree file at ``path``.
Args:
metadata: The chunk's stored metadata.
path: The file's key path in the source's ``directory_structure``.
Returns:
True when the chunk's ``source`` or ``file_path`` names that file, or
when the worker could only have keyed it by title: a remote ingest
(web page, Reddit post) whose chunks carry no ``file_path`` or ``key``
(see ``remote_worker``). Sources ingested that way stay browsable
without a re-ingest.
"""
source = metadata.get("source") or ""
file_path = metadata.get("file_path") or ""
if _path_ends_with(source, path) or _path_ends_with(file_path, path):
return True
if "://" in source and not file_path and not metadata.get("key"):
return metadata.get("title") == path
return False
@sources_chunks_ns.route("/get_chunks")
class GetChunks(Resource):
@api.doc(
@@ -141,14 +209,8 @@ class GetChunks(Resource):
for chunk in chunks:
metadata = chunk.get("metadata", {})
if path:
chunk_source = metadata.get("source", "")
chunk_file_path = metadata.get("file_path", "")
source_match = chunk_source and chunk_source.endswith(path)
file_path_match = chunk_file_path and chunk_file_path.endswith(path)
if not (source_match or file_path_match):
continue
if path and not _chunk_matches_path(metadata, path):
continue
if search_term:
text_match = search_term in chunk.get("text", "").lower()
title_match = search_term in metadata.get("title", "").lower()
@@ -339,13 +401,11 @@ class UpdateChunk(Resource):
if text is not None:
new_metadata["token_count"] = num_tokens_from_string(new_text)
try:
new_chunk_id = store.add_chunk(new_text, new_metadata)
deleted = store.delete_chunk(chunk_id)
if not deleted:
current_app.logger.warning(
f"Failed to delete old chunk {chunk_id}, but new chunk {new_chunk_id} was created"
)
# In place where the store supports it (same id, same list
# position); the base fallback re-adds under a new id.
new_chunk_id = store.update_chunk(chunk_id, new_text, new_metadata)
if new_chunk_id != chunk_id:
_remap_graph_chunk(doc, chunk_id, new_chunk_id)
return make_response(
jsonify(
{
@@ -356,8 +416,13 @@ class UpdateChunk(Resource):
),
200,
)
except InvalidChunkMetadataError as meta_error:
current_app.logger.warning(
f"Rejected metadata for chunk {chunk_id}: {meta_error}"
)
return make_response(jsonify({"error": "Invalid metadata"}), 400)
except Exception as add_error:
current_app.logger.error(f"Failed to add updated chunk: {add_error}")
current_app.logger.error(f"Failed to update chunk {chunk_id}: {add_error}")
return make_response(
jsonify({"error": "Failed to update chunk - addition failed"}), 500
)
+178 -7
View File
@@ -46,6 +46,9 @@ from docsgpt.vectorstore.vector_creator import VectorCreator
WIKI_INDEX_PATH = "/index.md"
# Longest graph-node search term forwarded to the store; longer input is truncated.
_GRAPH_SEARCH_MAX_LEN = 200
sources_ns = Namespace(
"sources", description="Source document management operations", path="/api"
@@ -545,7 +548,7 @@ class DirectoryStructure(Resource):
return make_response(jsonify({"error": "Document ID is required"}), 400)
try:
with db_readonly() as conn:
doc = SourcesRepository(conn).get_any(doc_id, user)
doc = _resolve_readable_source(conn, doc_id, user)
if not doc:
return make_response(
jsonify({"error": "Document not found or access denied"}), 404
@@ -1247,18 +1250,50 @@ class EnableSourceGraphRAG(Resource):
)
def _graph_overview_payload(source_id, limit):
"""Return a bounded ``{nodes, edges}`` overview for a graphrag source.
def _graph_overview_payload(source_id: str, limit: int) -> dict:
"""Return a bounded ``{nodes, edges, stats}`` overview for a graphrag source.
An empty graph (extraction pending/capped/failed) yields empty lists rather
than an error, mirroring the ClassicRAG degradation guarantee.
than an error, mirroring the ClassicRAG degradation guarantee, and skips
the overview query entirely. ``stats`` carries the whole graph's totals,
not the bounded overview's.
Args:
source_id: The resolved source id.
limit: Requested overview size; the store clamps it.
Returns:
dict: ``{"nodes": [...], "edges": [...], "stats": {"nodes": int,
"edges": int}}``.
"""
from docsgpt.graphrag.store import GraphStore
store = GraphStore()
if store.count_nodes(source_id) == 0:
return {"nodes": [], "edges": []}
return store.get_graph_overview(source_id, limit)
node_count = store.count_nodes(source_id)
if node_count == 0:
return {"nodes": [], "edges": [], "stats": {"nodes": 0, "edges": 0}}
overview = store.get_graph_overview(source_id, limit)
return {
"nodes": overview["nodes"],
"edges": overview["edges"],
"stats": {"nodes": node_count, "edges": store.count_edges(source_id)},
}
def _int_arg(name: str, default: int) -> int:
"""Read an integer query arg, falling back to ``default`` when unparsable.
Args:
name: Query-string parameter name.
default: Value used when the arg is missing or not an integer.
Returns:
int: The parsed value or ``default``.
"""
try:
return int(request.args.get(name, default))
except (TypeError, ValueError):
return default
@sources_ns.route("/sources/<string:source_id>/graph")
@@ -1310,6 +1345,142 @@ class SourceGraph(Resource):
"success": True,
"nodes": overview["nodes"],
"edges": overview["edges"],
"stats": overview["stats"],
}
),
200,
)
def _graph_node_page(
source_id: str,
query: str | None,
type_key: str | None,
page: int,
per_page: int,
) -> tuple[dict, list]:
"""Return ``(listing, type_facets)`` for one page of a source's graph nodes.
A graph store that is not configured, or whose tables do not exist yet
(no graph has ever been built on this deployment), yields an empty page,
matching :func:`_graph_overview_payload`. Every other failure propagates,
so a broken query is never shown as an empty graph.
Args:
source_id: The resolved source id.
query: Name substring filter, or ``None``.
type_key: Type key filter, or ``None`` for no filter.
page: 1-based page, already clamped.
per_page: Page size, already clamped.
Returns:
tuple: ``({"nodes": [...], "total": int}, [{"key", "label", "count"}])``.
Raises:
Exception: Any store failure other than a missing store or schema.
"""
import psycopg
from docsgpt.graphrag.store import GraphStore
empty = ({"nodes": [], "total": 0}, [])
try:
store = GraphStore()
except (ValueError, ImportError) as err:
current_app.logger.info("Graph store unavailable, listing no nodes: %s", err)
return empty
try:
listing = store.list_nodes(
source_id,
query=query,
type_key=type_key,
offset=(page - 1) * per_page,
limit=per_page,
)
types = store.node_type_facets(source_id)
except psycopg.errors.UndefinedTable as err:
current_app.logger.info("Graph tables missing, listing no nodes: %s", err)
return empty
return listing, types
@sources_ns.route("/sources/<string:source_id>/graph/nodes")
class SourceGraphNodes(Resource):
@api.doc(
description="Paged, searchable list of a graphrag source's nodes, "
"highest degree first, plus type facets over all its nodes (read "
"access: owner or shared). Query params: q (name substring), type "
"(type key), page (1-based), per_page (1-100, default 25)."
)
def get(self, source_id: str):
"""List one page of a source's graph nodes with type facets.
Args:
source_id: The source id from the URL.
Returns:
Response: ``{success, nodes, total, page, per_page, types}``, or
401/404/400 on missing auth, no read access or a failure. ``page``
is clamped to ``1..GRAPH_NODE_LIST_MAX_PAGE`` and ``per_page`` to
``1..GRAPH_NODE_LIST_MAX_LIMIT``. A graph store that is not
configured or has no tables yet reads as an empty page, like the
overview; any other store query failure is a 400, never an empty
200.
"""
decoded_token = request.decoded_token
if not decoded_token:
return make_response(jsonify({"success": False}), 401)
user = decoded_token.get("sub")
from docsgpt.graphrag.store import (
GRAPH_NODE_LIST_DEFAULT_LIMIT,
GRAPH_NODE_LIST_MAX_LIMIT,
GRAPH_NODE_LIST_MAX_PAGE,
)
page = max(1, min(_int_arg("page", 1), GRAPH_NODE_LIST_MAX_PAGE))
per_page = max(
1,
min(
_int_arg("per_page", GRAPH_NODE_LIST_DEFAULT_LIMIT),
GRAPH_NODE_LIST_MAX_LIMIT,
),
)
query = (request.args.get("q") or "").strip()[:_GRAPH_SEARCH_MAX_LEN] or None
type_key = request.args.get("type")
try:
with db_readonly() as conn:
doc = _resolve_readable_source(conn, source_id, user)
if doc is None:
return make_response(
jsonify({"success": False, "message": "Source not found"}),
404,
)
resolved_source_id = str(doc["id"])
except Exception as err:
current_app.logger.error(
f"Error resolving source {source_id} for graph nodes: {err}",
exc_info=True,
)
return make_response(jsonify({"success": False}), 400)
try:
listing, types = _graph_node_page(
resolved_source_id, query, type_key, page, per_page
)
except Exception as err:
current_app.logger.error(
f"Error listing graph nodes for {source_id}: {err}",
exc_info=True,
)
return make_response(jsonify({"success": False}), 400)
return make_response(
jsonify(
{
"success": True,
"nodes": listing["nodes"],
"total": listing["total"],
"page": page,
"per_page": per_page,
"types": types,
}
),
200,
+10 -5
View File
@@ -112,17 +112,22 @@ def validate_url(url: str, allow_localhost: bool = False) -> str:
"""
if not url or not isinstance(url, str):
raise SSRFError("No URL was provided.")
# Ensure URL has a scheme
if not urlparse(url).scheme:
url = "http://" + url
# ``urlparse`` and ``.hostname`` raise ValueError on malformed input such
# as an unclosed IPv6 bracket (``http://[bad``); callers only catch SSRFError.
try:
# Ensure URL has a scheme
if not urlparse(url).scheme:
url = "http://" + url
parsed = urlparse(url)
parsed = urlparse(url)
hostname = parsed.hostname
except ValueError as e:
raise SSRFError(f"Invalid URL: {e}") from e
# Check scheme
if parsed.scheme not in ALLOWED_SCHEMES:
raise SSRFError(f"URL scheme '{parsed.scheme}' is not allowed. Only HTTP(S) is permitted.")
hostname = parsed.hostname
if not hostname:
raise SSRFError("URL must have a valid hostname.")
+406 -4
View File
@@ -39,8 +39,49 @@ MAX_SUBGRAPH_EDGES = 2000
GRAPH_OVERVIEW_DEFAULT_LIMIT = 100
GRAPH_OVERVIEW_MAX_LIMIT = 250
GRAPH_NODE_LIST_DEFAULT_LIMIT = 25
GRAPH_NODE_LIST_MAX_LIMIT = 100
# Highest 1-based page the node-list route accepts. Keeps ``OFFSET`` (at most
# ~1e8 rows at the max page size) far inside Postgres' bigint range, so a huge
# ``page`` query arg yields an empty page with the real total, not an error.
GRAPH_NODE_LIST_MAX_PAGE = 1_000_000
MAX_NODE_RELATIONSHIPS = 300
PGVECTOR_SOURCE_COLUMN = "source_id"
def graph_type_key(type_value: Optional[str]) -> str:
"""Return the grouping key for a node type.
``"Person"``, ``"PERSON"`` and ``"per son"`` all fold to ``"person"``, so
the extractor's inconsistent spellings land in one facet. Computed in
Python on purpose, never in SQL: ``lower()`` and ``[:alnum:]`` follow the
database's ``LC_CTYPE``, and on a ``C``-locale database (common on managed
Postgres) they fold every Cyrillic or CJK type to ``""``. Must agree with
the frontend, which filters and colours by this key with
``/[^\\p{L}\\p{N}]/gu`` and ``toLowerCase``.
Args:
type_value: The raw node type, possibly ``None`` or empty.
Returns:
str: The lower-cased type with non-alphanumerics removed; ``""`` for a
missing type.
"""
return "".join(ch for ch in (type_value or "").lower() if ch.isalnum())
def _escape_like(value: str) -> str:
"""Escape ``value`` so ``LIKE``/``ILIKE`` treats it as a literal.
Args:
value: Raw user text.
Returns:
str: ``value`` with ``\\``, ``%`` and ``_`` backslash-escaped.
"""
return value.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
def _safe_identifier(name: str) -> str:
"""Return ``name`` if it is a bare SQL identifier, else raise.
@@ -857,6 +898,174 @@ class GraphStore:
cursor.close()
conn.rollback()
def count_edges(self, source_id: str) -> int:
"""Number of edges for a source.
Args:
source_id: Source whose edges to count.
Returns:
int: The edge count, or ``0`` when the query fails.
"""
conn = self._get_connection()
cursor = conn.cursor()
try:
cursor.execute(
"SELECT count(*) FROM graph_edges WHERE source_id = %s;",
(source_id,),
)
return int(cursor.fetchone()[0])
except Exception as e:
logging.error(f"Error counting edges: {e}")
return 0
finally:
cursor.close()
conn.rollback()
def list_nodes(
self,
source_id: str,
query: Optional[str] = None,
type_key: Optional[str] = None,
offset: int = 0,
limit: int = GRAPH_NODE_LIST_DEFAULT_LIMIT,
) -> Dict[str, Any]:
"""One page of a source's nodes, highest degree first, with the match count.
Args:
source_id: Source whose nodes to list.
query: Case-insensitive substring of the node name; ``%``, ``_``
and ``\\`` match literally. Blank means no name filter.
type_key: Only nodes whose :func:`graph_type_key` equals this;
``""`` selects untyped nodes and ``None`` applies no filter.
offset: Rows to skip (floored at 0).
limit: Page size, clamped to ``1..GRAPH_NODE_LIST_MAX_LIMIT``.
Returns:
dict: ``{"nodes": [{id, name, type, degree, doc_freq}], "total": int}``
where ``total`` counts every match, not just this page. A source
with no graph yields ``{"nodes": [], "total": 0}``.
Raises:
Exception: Any query failure, so a caller never mistakes a broken
query for an empty graph.
"""
limit = max(1, min(int(limit), GRAPH_NODE_LIST_MAX_LIMIT))
offset = max(0, int(offset))
clauses = ["source_id = %s"]
params: List[Any] = [source_id]
clean = (query or "").strip()
if clean:
clauses.append("name ILIKE %s ESCAPE '\\'")
params.append(f"%{_escape_like(clean)}%")
conn = self._get_connection()
cursor = conn.cursor()
try:
if type_key is not None:
# The key is folded in Python (see ``graph_type_key``), so the
# filter matches the raw spellings whose key equals it.
cursor.execute(
"SELECT DISTINCT type FROM graph_nodes WHERE source_id = %s;",
(source_id,),
)
raw_types = sorted(
row[0]
for row in cursor.fetchall()
if row[0] is not None and graph_type_key(row[0]) == type_key
)
if type_key == "":
clauses.append("(type IS NULL OR type = ANY(%s))")
elif not raw_types:
return {"nodes": [], "total": 0}
else:
clauses.append("type = ANY(%s)")
params.append(raw_types)
where = " AND ".join(clauses)
cursor.execute(
f"SELECT count(*) FROM graph_nodes WHERE {where};", tuple(params)
)
total = int(cursor.fetchone()[0])
cursor.execute(
f"""
SELECT id, name, type, degree, doc_freq
FROM graph_nodes
WHERE {where}
ORDER BY degree DESC, id
LIMIT %s OFFSET %s;
""",
(*params, limit, offset),
)
nodes = [
{
"id": str(row[0]),
"name": row[1],
"type": row[2],
"degree": row[3],
"doc_freq": row[4],
}
for row in cursor.fetchall()
]
return {"nodes": nodes, "total": total}
finally:
cursor.close()
conn.rollback()
def node_type_facets(self, source_id: str) -> List[Dict[str, Any]]:
"""Node counts per type key over every node of a source.
Spellings that share a :func:`graph_type_key` are folded into one
facet labelled with the most frequent spelling (ties go to the
alphabetically first). Untyped nodes form the ``""`` facet with a
``None`` label.
Args:
source_id: Source whose node types to count.
Returns:
list: ``[{"key", "label", "count"}]`` sorted by count descending,
then label. Empty for a source with no graph.
Raises:
Exception: Any query failure, so a caller never mistakes a broken
query for an empty graph.
"""
conn = self._get_connection()
cursor = conn.cursor()
try:
# Group by the raw spelling only; the key is folded in Python so it
# does not depend on the database locale (see ``graph_type_key``).
cursor.execute(
"""
SELECT type, count(*)
FROM graph_nodes
WHERE source_id = %s
GROUP BY type;
""",
(source_id,),
)
rows = cursor.fetchall()
finally:
cursor.close()
conn.rollback()
spellings: Dict[str, List[tuple]] = {}
for raw_type, n in rows:
spellings.setdefault(graph_type_key(raw_type), []).append(
(int(n), raw_type)
)
facets = []
for key, variants in spellings.items():
label = None
if key:
# Most frequent spelling; a tie goes to the alphabetically first.
label = min(variants, key=lambda v: (-v[0], v[1]))[1]
facets.append(
{"key": key, "label": label, "count": sum(v[0] for v in variants)}
)
facets.sort(
key=lambda f: (-f["count"], f["label"] is None, f["label"] or "")
)
return facets
def count_nodes_many(self, source_ids: List[str]) -> Dict[str, int]:
"""Node counts for several sources in one round trip.
@@ -1390,11 +1599,26 @@ class GraphStore:
def get_node_detail(
self, source_id: str, node_id: str, max_chunks: int = 20
) -> Optional[Dict[str, Any]]:
"""A node's full record plus a bounded list of its linked chunks.
"""A node's full record, its relationships and a bounded list of its chunks.
Returns ``None`` when the node does not belong to the source. Chunk texts
are read from the co-located pgvector table; at most ``max_chunks`` are
returned so a hub node never streams an unbounded payload.
Chunk texts are read from the co-located pgvector table; at most
``max_chunks`` are returned so a hub node never streams an unbounded
payload. ``relationships`` holds every edge touching the node in either
direction (self-loops skipped), joined to the other endpoint, strongest
neighbour first and capped at ``MAX_NODE_RELATIONSHIPS``;
``relationships_total`` is how many such edges there are, so a capped
list can say what it leaves out.
Args:
source_id: Source the node must belong to.
node_id: The node's id.
max_chunks: Most linked chunks to return.
Returns:
dict | None: ``{id, name, type, description, degree, doc_freq,
relationships: [{id, name, type, degree, edge_type, direction}],
relationships_total, chunks: [{chunk_id, text, metadata}]}``; ``None`` when the node does
not belong to the source or the read fails.
"""
conn = self._get_connection()
try:
@@ -1421,6 +1645,15 @@ class GraphStore:
"degree": row[4],
"doc_freq": row[5],
}
relationships = self._node_relationships(conn, source_id, node_id)
node["relationships"] = relationships
node["relationships_total"] = (
self._node_relationships_total(
conn, source_id, node_id, len(relationships)
)
if len(relationships) >= MAX_NODE_RELATIONSHIPS
else len(relationships)
)
chunk_ids = self.get_chunk_ids_for_nodes(source_id, [node_id]).get(
str(node_id), []
@@ -1443,6 +1676,107 @@ class GraphStore:
finally:
conn.rollback()
def _node_relationships(
self, conn, source_id: str, node_id: str
) -> List[Dict[str, Any]]:
"""Edges touching ``node_id``, joined to the neighbour at the other end.
Args:
conn: Open connection; the caller owns the transaction.
source_id: Source the edges and neighbours must belong to.
node_id: The node whose relationships to read.
Returns:
list: ``[{id, name, type, degree, edge_type, direction}]`` where
``direction`` is ``"out"`` when the node is the edge's source,
ordered by neighbour degree descending, neighbour id, edge type.
Empty when the read fails (the transaction is rolled back).
"""
cursor = conn.cursor()
try:
cursor.execute(
"""
SELECT o.id, o.name, o.type, o.degree, e.type,
CASE WHEN e.src_node_id = %s THEN 'out' ELSE 'in' END AS direction
FROM graph_edges e
JOIN graph_nodes o
ON o.id = CASE WHEN e.src_node_id = %s
THEN e.dst_node_id ELSE e.src_node_id END
WHERE e.source_id = %s
AND (e.src_node_id = %s OR e.dst_node_id = %s)
AND e.src_node_id <> e.dst_node_id
AND o.source_id = %s
ORDER BY o.degree DESC, o.id, e.type
LIMIT %s;
""",
(
node_id, node_id, source_id, node_id, node_id, source_id,
MAX_NODE_RELATIONSHIPS,
),
)
return [
{
"id": str(row[0]),
"name": row[1],
"type": row[2],
"degree": row[3],
"edge_type": row[4],
"direction": row[5],
}
for row in cursor.fetchall()
]
except Exception as e:
# Degrade to no relationships rather than failing the whole node
# detail; roll back so the caller's chunk read can reuse ``conn``.
logging.error(f"Error reading node relationships: {e}")
conn.rollback()
return []
finally:
cursor.close()
def _node_relationships_total(
self, conn, source_id: str, node_id: str, fallback: int
) -> int:
"""How many edges ``_node_relationships`` would return without its cap.
Same filters as the list: either direction, self-loops skipped, the
neighbour in the same source.
Args:
conn: Open connection; the caller owns the transaction.
source_id: Source the edges and neighbours must belong to.
node_id: The node whose relationships to count.
fallback: What to return when the count fails (the list's length).
Returns:
int: The edge count, or ``fallback`` when the read fails (the
transaction is rolled back).
"""
cursor = conn.cursor()
try:
cursor.execute(
"""
SELECT COUNT(*)
FROM graph_edges e
JOIN graph_nodes o
ON o.id = CASE WHEN e.src_node_id = %s
THEN e.dst_node_id ELSE e.src_node_id END
WHERE e.source_id = %s
AND (e.src_node_id = %s OR e.dst_node_id = %s)
AND e.src_node_id <> e.dst_node_id
AND o.source_id = %s;
""",
(node_id, source_id, node_id, node_id, source_id),
)
row = cursor.fetchone()
return int(row[0]) if row else fallback
except Exception as e:
logging.error(f"Error counting node relationships: {e}")
conn.rollback()
return fallback
finally:
cursor.close()
def set_node_degrees(self, source_id: str):
"""Recompute every node's degree from its incident edges for a source.
@@ -1506,6 +1840,74 @@ class GraphStore:
return self._write_with_reconnect(_write)
def remap_chunk(self, source_id: str, old_chunk_id: str, new_chunk_id: str) -> None:
"""Point every graph reference to a chunk at its replacement id.
A chunk edit re-adds the chunk under a new id and deletes the old row,
so without this the node links, edge provenance and extraction
checkpoint would all name a chunk that no longer exists. The graph
itself is not re-extracted from the edited text.
Args:
source_id: Source the chunk belongs to.
old_chunk_id: The id the graph currently references.
new_chunk_id: The id of the chunk that replaced it.
Raises:
Exception: The underlying write failure, after a rollback.
"""
self._ensure_tables_once()
old_id, new_id = str(old_chunk_id), str(new_chunk_id)
def _write(conn):
cursor = conn.cursor()
try:
_lock_source(cursor, source_id)
cursor.execute(
"""
UPDATE graph_node_chunks SET chunk_id = %s
WHERE source_id = %s AND chunk_id = %s;
""",
(new_id, source_id, old_id),
)
# Rewrite the matching element in place, keeping array order.
cursor.execute(
"""
UPDATE graph_edges
SET source_chunk_ids = (
SELECT jsonb_agg(
CASE WHEN t.v #>> '{}' = %s THEN to_jsonb(%s::text) ELSE t.v END
ORDER BY t.ord
)
FROM jsonb_array_elements(source_chunk_ids)
WITH ORDINALITY AS t(v, ord)
)
WHERE source_id = %s
AND jsonb_typeof(source_chunk_ids) = 'array'
AND EXISTS (
SELECT 1 FROM jsonb_array_elements(source_chunk_ids) AS s(v)
WHERE s.v #>> '{}' = %s
);
""",
(old_id, new_id, source_id, old_id),
)
cursor.execute(
"""
UPDATE graph_ingest_progress SET chunk_id = %s
WHERE source_id = %s AND chunk_id = %s;
""",
(new_id, source_id, old_id),
)
conn.commit()
except Exception as e:
_safe_rollback(conn)
logging.error(f"Error remapping graph chunk: {e}")
raise
finally:
cursor.close()
return self._write_with_reconnect(_write)
def pending_chunks(self, source_id: str, all_chunk_ids: List[str]) -> List[str]:
"""Chunk ids from ``all_chunk_ids`` not yet marked ``done`` for the source."""
if not all_chunk_ids:
+182 -1
View File
@@ -1,10 +1,191 @@
"""Base reader class."""
import hashlib
import os
import re
from abc import abstractmethod
from typing import Any, List
from typing import Any, Dict, List
from urllib.parse import parse_qsl, urlencode, urlparse, urlsplit, urlunsplit
from docsgpt.parser.schema.base import Document
from docsgpt.vectorstore.document_class import Document as VectorDocument
_PAGE_EXTENSIONS = (".html", ".htm", ".php", ".asp", ".aspx", ".jsp")
# Joins a page's path to its encoded query string in the virtual file name.
_QUERY_SEPARATOR = "__"
# Longest encoded query kept verbatim; longer ones keep a prefix plus a hash.
MAX_QUERY_SEGMENT_LENGTH = 64
_QUERY_HASH_LENGTH = 10
# Anything but letters, digits, ``_``, ``.`` and ``-`` in a query key or value.
_UNSAFE_QUERY_CHARS = re.compile(r"[^\w.-]+")
def _query_segment(query: str) -> str:
"""Encode a URL query string as a deterministic, tree-safe name segment.
Parameters are sorted so the same page maps to the same name across
re-syncs whatever order its links list them in. Keys and values are
percent-decoded and every run of characters outside ``[\\w.-]`` becomes
``-``, so the segment carries no ``/``, ``?``, ``#`` or ``%``. A segment
longer than ``MAX_QUERY_SEGMENT_LENGTH`` keeps a prefix and a hash of the
full query, which keeps it bounded and still distinct.
Args:
query: The raw query string, without the leading ``?``.
Returns:
The encoded segment, or ``""`` when the query has no parameters.
"""
pairs = sorted(parse_qsl(query, keep_blank_values=True))
if not pairs:
return ""
parts = []
for key, value in pairs:
key = _UNSAFE_QUERY_CHARS.sub("-", key)
value = _UNSAFE_QUERY_CHARS.sub("-", value)
parts.append(f"{key}={value}" if value else key)
segment = "&".join(parts)
if len(segment) <= MAX_QUERY_SEGMENT_LENGTH:
return segment
digest = hashlib.sha1(urlencode(pairs).encode("utf-8")).hexdigest()
keep = MAX_QUERY_SEGMENT_LENGTH - _QUERY_HASH_LENGTH - 1
return f"{segment[:keep]}-{digest[:_QUERY_HASH_LENGTH]}"
def url_to_virtual_path(url: str, include_host: bool = False) -> str:
"""Convert a page URL to the virtual ``.md`` path used as its tree key.
Web ingests store this as the chunk's ``file_path``: the worker keys the
source's file tree by it and the chunks view filters by it, so the two
agree on which chunks belong to which file.
The fragment is dropped (it names a spot on the same page), but the query
string is kept, encoded into the file name (see ``_query_segment``), so
``/p?page=1`` and ``/p?page=2`` stay two files. A URL without a query maps
exactly as before. Paths that still collide, such as ``/a`` and
``/a.html``, are told apart by ``dedupe_virtual_paths``.
Args:
url: Page URL, e.g. ``"https://docs.docsgpt.cloud/guides/setup"``.
include_host: Prefix the host, for ingests spanning several hosts
whose paths would otherwise collide (every root is ``index.md``).
Returns:
A relative path such as ``"index.md"``, ``"guides/setup.md"``,
``"list__page=2.md"`` or, with ``include_host``,
``"docs.docsgpt.cloud/guides/setup.md"``.
"""
parsed = urlparse(url)
path = parsed.path.strip("/")
if not path:
path = "index.md"
else:
base, ext = os.path.splitext(path)
if ext.lower() in _PAGE_EXTENSIONS:
path = base
if not path.endswith(".md"):
path = f"{path}.md"
query = _query_segment(parsed.query)
if query:
path = f"{path[:-len('.md')]}{_QUERY_SEPARATOR}{query}.md"
if include_host and parsed.netloc:
return f"{parsed.netloc}/{path}"
return path
def spans_multiple_hosts(urls: List[str]) -> bool:
"""Return whether ``urls`` point at more than one host.
A URL ``urlparse`` rejects (an unclosed IPv6 bracket, say) is left out:
it fails validation and is never fetched, so it names no host here.
Args:
urls: Page URLs about to be ingested together.
Returns:
True when paths alone could collide and need a host prefix.
"""
hosts = set()
for url in urls:
try:
hosts.add(urlparse(url).netloc)
except ValueError:
continue
return len(hosts) > 1
def normalize_page_url(url: str) -> str:
"""Return the identity of the page ``url`` names, for deduplication.
The fragment is dropped (it names a spot on the same page) and the query
parameters are sorted, as ``_query_segment`` sorts them, so
``/p?a=1&b=2`` and ``/p?b=2&a=1#top`` are one page. The result is a key
only; fetch the URL as it was written.
Args:
url: Page URL.
Returns:
The normalized URL.
Raises:
ValueError: If ``urlsplit`` cannot parse ``url``.
"""
parts = urlsplit(url)
query = urlencode(sorted(parse_qsl(parts.query, keep_blank_values=True)))
return urlunsplit(parts._replace(query=query, fragment=""))
def dedupe_virtual_paths(documents: List[Document]) -> List[Document]:
"""Give distinct pages that share a virtual ``file_path`` distinct ones.
``url_to_virtual_path`` folds some different URLs onto one path (``/a`` and
``/a.html`` are both ``a.md``), and the worker would then merge those pages
into one tree entry. Within a path, the pages are ordered by URL (fragment
dropped, query parameters sorted; see ``normalize_page_url``): the first
keeps the path and each later one gets ``-2``, ``-3`` and so on before
``.md``, skipping any path another page already has.
Ordering by URL rather than by fetch order keeps the names stable across
re-syncs of the same pages. Documents for the same URL (one page reached
via two fragments, or with its query parameters in another order) are one
page and keep one path.
Args:
documents: Loaded documents; each ``extra_info`` carries ``source``
(the page URL) and ``file_path``. Others are left untouched.
Returns:
``documents``, with colliding ``file_path`` values rewritten in place.
"""
pages_by_path: Dict[str, Dict[str, List[Document]]] = {}
for doc in documents:
info = doc.extra_info or {}
path = info.get("file_path")
if not path:
continue
page = normalize_page_url(str(info.get("source") or path))
pages_by_path.setdefault(path, {}).setdefault(page, []).append(doc)
taken = set(pages_by_path)
for path in sorted(pages_by_path):
pages = pages_by_path[path]
if len(pages) < 2:
continue
stem = path[: -len(".md")] if path.endswith(".md") else path
suffix = 1
for page in sorted(pages)[1:]:
suffix += 1
while f"{stem}-{suffix}.md" in taken:
suffix += 1
new_path = f"{stem}-{suffix}.md"
taken.add(new_path)
for doc in pages[page]:
doc.extra_info["file_path"] = new_path
return documents
class BaseRemote:
"""Utilities for loading data from a directory."""
+23 -35
View File
@@ -1,9 +1,8 @@
import logging
import os
from bs4 import BeautifulSoup
from urllib.parse import urljoin, urlparse
from docsgpt.parser.remote.base import BaseRemote
from docsgpt.parser.remote.base import BaseRemote, dedupe_virtual_paths, normalize_page_url, url_to_virtual_path
from docsgpt.parser.schema.base import Document
from docsgpt.core.url_validation import validate_url, SSRFError
from docsgpt.security.safe_url import pinned_request
@@ -25,6 +24,8 @@ class CrawlerLoader(BaseRemote):
logging.error(f"URL validation failed: {e}")
return []
# Keyed by normalize_page_url, so fragment and query-order variants of
# one page are fetched once.
visited_urls = set()
base_url = urlparse(url).scheme + "://" + urlparse(url).hostname
urls_to_visit = [url]
@@ -32,7 +33,10 @@ class CrawlerLoader(BaseRemote):
while urls_to_visit:
current_url = urls_to_visit.pop(0)
visited_urls.add(current_url)
page_key = normalize_page_url(current_url)
if page_key in visited_urls:
continue
visited_urls.add(page_key)
try:
response = pinned_request("GET", current_url, timeout=30)
@@ -58,45 +62,29 @@ class CrawlerLoader(BaseRemote):
logging.error(f"Error processing URL {current_url}: {e}", exc_info=True)
continue
# Parse the HTML content to extract all links
all_links = [
urljoin(current_url, a['href'])
for a in soup.find_all('a', href=True)
if base_url in urljoin(current_url, a['href'])
]
# Parse the HTML content to extract all links. A malformed href
# (``http://[bad``) makes urljoin or the key parse raise; skip it
# rather than abort the crawl.
all_links = []
for a in soup.find_all('a', href=True):
try:
link = urljoin(current_url, a['href'])
link_key = normalize_page_url(link)
except ValueError:
continue
if base_url in link and link_key not in visited_urls:
all_links.append(link)
# Add new links to the list of URLs to visit if they haven't been visited yet
urls_to_visit.extend([link for link in all_links if link not in visited_urls])
urls_to_visit.extend(all_links)
urls_to_visit = list(set(urls_to_visit))
# Stop crawling if the limit of pages to scrape is reached
if self.limit is not None and len(visited_urls) >= self.limit:
break
return loaded_content
return dedupe_virtual_paths(loaded_content)
def _url_to_virtual_path(self, url):
"""
Convert a URL to a virtual file path ending with .md.
Examples:
https://docs.docsgpt.cloud/ -> index.md
https://docs.docsgpt.cloud/guides/setup -> guides/setup.md
https://docs.docsgpt.cloud/guides/setup/ -> guides/setup.md
https://example.com/page.html -> page.md
"""
parsed = urlparse(url)
path = parsed.path.strip("/")
if not path:
return "index.md"
# Remove common file extensions and add .md
base, ext = os.path.splitext(path)
if ext.lower() in [".html", ".htm", ".php", ".asp", ".aspx", ".jsp"]:
path = base
if not path.endswith(".md"):
path = f"{path}.md"
return path
"""Convert a URL to a virtual ``.md`` path; see ``url_to_virtual_path``."""
return url_to_virtual_path(url)
+19 -33
View File
@@ -1,13 +1,12 @@
from urllib.parse import urlparse, urljoin
from bs4 import BeautifulSoup
from docsgpt.parser.remote.base import BaseRemote
from docsgpt.parser.remote.base import BaseRemote, dedupe_virtual_paths, normalize_page_url, url_to_virtual_path
from docsgpt.core.url_validation import validate_url, SSRFError
from docsgpt.security.safe_url import UnsafeUserUrlError, pinned_request
import re
from markdownify import markdownify
from docsgpt.parser.schema.base import Document
import tldextract
import os
# The bundled public-suffix snapshot is enough for domain matching; the
# default extractor would fetch the live list on first use and cache it on
@@ -38,7 +37,8 @@ class CrawlerLoader(BaseRemote):
print(f"URL validation failed: {e}")
return []
# Keep track of visited URLs to avoid revisiting the same page
# Keep track of visited pages to avoid revisiting the same page. Keyed
# by normalize_page_url, so fragment and query-order variants are one.
visited_urls = set()
# Determine the base domain for link filtering using tldextract
@@ -50,9 +50,10 @@ class CrawlerLoader(BaseRemote):
current_url = urls_to_visit.pop()
# Skip if already visited
if current_url in visited_urls:
page_key = normalize_page_url(current_url)
if page_key in visited_urls:
continue
visited_urls.add(current_url)
visited_urls.add(page_key)
# Fetch the page content
html_content = self._fetch_page(current_url)
@@ -85,13 +86,15 @@ class CrawlerLoader(BaseRemote):
filtered_links = self._filter_links(new_links, base_domain)
# Add any new, not-yet-visited links to the queue
urls_to_visit.update(link for link in filtered_links if link not in visited_urls)
urls_to_visit.update(
link for link in filtered_links if normalize_page_url(link) not in visited_urls
)
# If we've reached the limit, stop crawling
if self.limit is not None and len(visited_urls) >= self.limit:
break
return documents
return dedupe_virtual_paths(documents)
def _fetch_page(self, url):
try:
@@ -124,7 +127,13 @@ class CrawlerLoader(BaseRemote):
soup = BeautifulSoup(html_content, 'html.parser')
links = []
for a in soup.find_all('a', href=True):
full_url = urljoin(current_url, a['href'])
# A malformed href (``http://[bad``) makes urljoin or the later
# parse raise; skip that link rather than abort the crawl.
try:
full_url = urljoin(current_url, a['href'])
normalize_page_url(full_url)
except ValueError:
continue
links.append((full_url, a.text.strip()))
return links
@@ -160,28 +169,5 @@ class CrawlerLoader(BaseRemote):
return filtered
def _url_to_virtual_path(self, url):
"""
Convert a URL to a virtual file path ending with .md.
Examples:
https://docs.docsgpt.cloud/ -> index.md
https://docs.docsgpt.cloud/guides/setup -> guides/setup.md
https://docs.docsgpt.cloud/guides/setup/ -> guides/setup.md
https://example.com/page.html -> page.md
"""
parsed = urlparse(url)
path = parsed.path.strip("/")
if not path:
return "index.md"
# Remove common file extensions and add .md
base, ext = os.path.splitext(path)
if ext.lower() in [".html", ".htm", ".php", ".asp", ".aspx", ".jsp"]:
path = base
# Ensure path ends with .md
if not path.endswith(".md"):
path = path + ".md"
return path
"""Convert a URL to a virtual ``.md`` path; see ``url_to_virtual_path``."""
return url_to_virtual_path(url)
+15 -3
View File
@@ -4,7 +4,12 @@ import re
import defusedxml.ElementTree as ET
from bs4 import BeautifulSoup
from docsgpt.parser.remote.base import BaseRemote
from docsgpt.parser.remote.base import (
BaseRemote,
dedupe_virtual_paths,
spans_multiple_hosts,
url_to_virtual_path,
)
from docsgpt.parser.schema.base import Document
from docsgpt.core.url_validation import validate_url, SSRFError
from docsgpt.security.safe_url import UnsafeUserUrlError, pinned_request
@@ -32,6 +37,7 @@ class SitemapLoader(BaseRemote):
return []
# Load content of extracted URLs
include_host = spans_multiple_hosts(urls)
documents = []
processed_urls = 0 # Counter for processed URLs
for url in urls:
@@ -50,7 +56,13 @@ class SitemapLoader(BaseRemote):
documents.append(
Document(
soup.get_text(separator="\n", strip=True),
extra_info={"source": url},
# Without file_path the worker had no tree key (no
# title, key or doc_id), so sitemap pages never
# appeared in the file tree.
extra_info={
"source": url,
"file_path": url_to_virtual_path(url, include_host),
},
)
)
processed_urls += 1 # Increment the counter after processing each URL
@@ -58,7 +70,7 @@ class SitemapLoader(BaseRemote):
logging.error(f"Error processing URL {url}: {e}", exc_info=True)
continue
return documents
return dedupe_virtual_paths(documents)
def _extract_urls(self, sitemap_url):
try:
+15 -5
View File
@@ -3,7 +3,12 @@ import logging
from bs4 import BeautifulSoup
from docsgpt.core.url_validation import SSRFError, validate_url
from docsgpt.parser.remote.base import BaseRemote
from docsgpt.parser.remote.base import (
BaseRemote,
dedupe_virtual_paths,
spans_multiple_hosts,
url_to_virtual_path,
)
from docsgpt.parser.schema.base import Document
from docsgpt.security.safe_url import pinned_request
@@ -24,15 +29,17 @@ class WebLoader(BaseRemote):
urls = inputs
if isinstance(urls, str):
urls = [urls]
documents = []
valid_urls = []
for url in urls:
try:
url = validate_url(url)
valid_urls.append(validate_url(url))
except SSRFError as e:
logging.warning(
f"Skipping URL due to SSRF validation failure: {url} - {e}"
)
continue
include_host = spans_multiple_hosts(valid_urls)
documents = []
for url in valid_urls:
try:
response = pinned_request("GET", url, headers=headers, timeout=30)
response.raise_for_status()
@@ -45,6 +52,9 @@ class WebLoader(BaseRemote):
html_tag = soup.find("html")
if html_tag and html_tag.get("lang"):
metadata["language"] = html_tag.get("lang")
# The worker keys the file tree by file_path; without it the
# tree fell back to the title, which the chunks view can't match.
metadata["file_path"] = url_to_virtual_path(url, include_host)
documents.append(
Document(
soup.get_text(separator="\n", strip=True),
@@ -54,4 +64,4 @@ class WebLoader(BaseRemote):
except Exception as e:
logging.error(f"Error processing URL {url}: {e}", exc_info=True)
continue
return documents
return dedupe_virtual_paths(documents)
@@ -167,6 +167,81 @@ class ScheduleRunsRepository:
).fetchall()
return [row_to_dict(r) for r in rows]
def stats_for_agent(
self, agent_id: str, user_id: str, *, days: int = 30,
) -> dict:
"""Aggregate run stats for an agent's owned schedules over a window.
Only runs of schedules owned by ``user_id`` on ``agent_id`` whose
``scheduled_for`` falls within the last ``days`` days are counted;
runs scheduled in the future are not.
Args:
agent_id: Agent UUID the schedules belong to.
user_id: Owner of the schedules (and runs).
days: Window length in days, counted back from now.
Returns:
Dict with ``runs``, ``failed`` (``failed``/``timeout``),
``tokens`` (prompt + generated) and ``latest_failure`` — the
most recent failed run's ``scheduled_for``/``status``/
``error_type``, or ``None`` when there is none.
"""
row = self._conn.execute(
text(
"""
WITH window_runs AS (
SELECT r.scheduled_for, r.status, r.error_type,
r.prompt_tokens, r.generated_tokens
FROM schedule_runs r
JOIN schedules s ON s.id = r.schedule_id
WHERE s.agent_id = CAST(:agent_id AS uuid)
AND s.user_id = :user_id
AND r.user_id = :user_id
AND r.scheduled_for >= now() - make_interval(days => :days)
AND r.scheduled_for <= now()
),
latest_failure AS (
SELECT scheduled_for, status, error_type
FROM window_runs
WHERE status IN ('failed', 'timeout')
ORDER BY scheduled_for DESC
LIMIT 1
)
SELECT agg.runs, agg.failed, agg.tokens,
lf.scheduled_for AS latest_failure_scheduled_for,
lf.status AS latest_failure_status,
lf.error_type AS latest_failure_error_type
FROM (
SELECT count(*) AS runs,
count(*) FILTER (
WHERE status IN ('failed', 'timeout')
) AS failed,
COALESCE(
SUM(prompt_tokens + generated_tokens), 0
) AS tokens
FROM window_runs
) agg
LEFT JOIN latest_failure lf ON true
"""
),
{"agent_id": str(agent_id), "user_id": user_id, "days": int(days)},
).fetchone()
data = row_to_dict(row)
latest_failure = None
if data.get("latest_failure_scheduled_for") is not None:
latest_failure = {
"scheduled_for": data["latest_failure_scheduled_for"],
"status": data["latest_failure_status"],
"error_type": data["latest_failure_error_type"],
}
return {
"runs": int(data.get("runs") or 0),
"failed": int(data.get("failed") or 0),
"tokens": int(data.get("tokens") or 0),
"latest_failure": latest_failure,
}
def update(self, run_id: str, fields: dict) -> Optional[dict]:
"""Apply a whitelisted partial update to a run row."""
filtered = {k: v for k, v in fields.items() if k in _ALLOWED_UPDATES}
+48
View File
@@ -362,6 +362,14 @@ def build_local_embeddings(
return embedding_instance
class InvalidChunkMetadataError(ValueError):
"""Chunk metadata the store cannot write, such as a key it reserves.
A client-input error, distinct from a store or embedding failure: the
chunk routes answer it with a 400 rather than a 500.
"""
class BaseVectorStore(ABC):
def __init__(self):
pass
@@ -431,6 +439,46 @@ class BaseVectorStore(ABC):
"""Delete a specific chunk from the vectorstore"""
pass
def update_chunk(self, chunk_id: str, text: str, metadata: dict) -> str:
"""Replace a chunk's text and metadata, returning the id it is now under.
Stores that can rewrite a row in place override this and keep both the
id and the chunk's position in :meth:`get_chunks`. This default works
for any store but re-adds the chunk and deletes the old one, so the
returned id differs and the chunk moves; callers holding the old id
(graph links, for one) must follow the returned id.
Args:
chunk_id: Id of the chunk to replace.
text: The chunk's new text.
metadata: The chunk's complete new metadata.
Returns:
The id the updated chunk is stored under.
Raises:
RuntimeError: The old chunk could not be deleted. The new chunk is
deleted again (best effort) so no duplicate is left behind.
"""
new_chunk_id = self.add_chunk(text, metadata)
delete_error: Optional[Exception] = None
try:
deleted = self.delete_chunk(chunk_id)
except Exception as err:
deleted, delete_error = False, err
if deleted:
return new_chunk_id
try:
self.delete_chunk(new_chunk_id)
except Exception:
logging.error(
"Failed to roll back new chunk %s after old chunk %s could not be deleted",
new_chunk_id,
chunk_id,
exc_info=True,
)
raise RuntimeError(f"Failed to delete old chunk {chunk_id} during update") from delete_error
def delete_chunks_by_source_path(self, path) -> int:
"""Delete every chunk whose ``metadata.source`` equals ``path``.
+52
View File
@@ -306,6 +306,58 @@ class FaissStore(BaseVectorStore):
self._save_to_storage()
return ids[0]
def update_chunk(self, chunk_id: str, text: str, metadata: Dict[str, Any]) -> str:
"""Replace a chunk in place, keeping its id and its place in the docstore.
The new vector is computed before anything changes, so a failed embed
leaves the chunk as it was. Its old row is removed from the index and
the new vector appended; the row mapping is renumbered to match, and
the docstore entry is replaced where it stands so :meth:`get_chunks`
keeps its order. Saved to storage once.
Args:
chunk_id: Id of the chunk to replace.
text: The chunk's new text.
metadata: The chunk's complete new metadata.
Returns:
``chunk_id``, unchanged.
Raises:
KeyError: If ``chunk_id`` is not in this index.
ValueError: If the new vector's width does not match the index.
"""
if chunk_id not in self.documents:
raise KeyError(f"Chunk id not found in index: {chunk_id}")
rows_by_id = {doc_id: row for row, doc_id in self.index_to_docstore_id.items()}
if chunk_id not in rows_by_id:
raise KeyError(f"Chunk id has no row in the FAISS index: {chunk_id}")
vector = np.array(self.embeddings.embed_documents([text]), dtype=np.float32)
if vector.ndim != 2 or vector.shape != (1, self.index.d):
raise ValueError(
f"Embedding for chunk {chunk_id} has shape {vector.shape}, "
f"expected (1, {self.index.d})"
)
row = rows_by_id[chunk_id]
self.index.remove_ids(np.array([row], dtype=np.int64))
self.index.add(vector)
# remove_ids compacts the index and add appends, so the edited chunk's
# vector is now the last row; renumber as delete_index does.
remaining = [
doc_id
for current_row, doc_id in sorted(self.index_to_docstore_id.items())
if current_row != row
]
self.index_to_docstore_id = dict(enumerate(remaining + [chunk_id]))
self.documents[chunk_id] = {
"page_content": text,
"metadata": dict(metadata or {}),
}
self._save_to_storage()
return chunk_id
def delete_chunk(self, chunk_id: str) -> bool:
"""Delete a chunk and save to storage."""
self.delete_index([chunk_id])
+63 -1
View File
@@ -2,7 +2,7 @@ import logging
from functools import cached_property
from docsgpt.core.settings import settings
from docsgpt.vectorstore.base import BaseVectorStore
from docsgpt.vectorstore.base import BaseVectorStore, InvalidChunkMetadataError
from docsgpt.vectorstore.document_class import Document
@@ -224,6 +224,68 @@ class MongoDBVectorStore(BaseVectorStore):
result = self._collection.insert_one(chunk_data)
return str(result.inserted_id)
def update_chunk(self, chunk_id: str, text: str, metadata: dict) -> str:
"""Rewrite a chunk's document in place, keeping its ``_id``.
Metadata lives as top-level fields, so the new metadata is ``$set``
and any field the record carries but the new metadata lacks is
``$unset``; the record then holds exactly the new metadata. ``_id``,
the text, the embedding and ``source_id`` are reserved and cannot be
overwritten through ``metadata``. The embedding is computed before
anything is written.
Args:
chunk_id: Id of the chunk to replace.
text: The chunk's new text.
metadata: The chunk's complete new metadata.
Returns:
``chunk_id``, unchanged.
Raises:
KeyError: If this source has no chunk with that id.
InvalidChunkMetadataError: If a metadata key is empty, contains
``.`` or starts with ``$``. Mongo reads those as a path or an
operator, so ``$set`` would fail or write a nested field.
ValueError: If no embedding could be generated.
"""
from bson.objectid import ObjectId
for key in metadata or {}:
if not isinstance(key, str) or not key or "." in key or key.startswith("$"):
raise InvalidChunkMetadataError(
f"Metadata key {key!r} is not allowed: keys must be non-empty, "
"contain no '.' and not start with '$'"
)
query = {"_id": ObjectId(chunk_id), "source_id": self._source_id}
existing = self._collection.find_one(query)
if existing is None:
raise KeyError(f"Chunk {chunk_id} not found for source {self._source_id}")
embeddings = self._embedding.embed_documents([text])
if not embeddings:
raise ValueError("Could not generate embedding for chunk")
reserved = {"_id", self._text_key, self._embedding_key, "source_id"}
fields = {k: v for k, v in (metadata or {}).items() if k not in reserved}
update = {
"$set": {
self._text_key: text,
self._embedding_key: embeddings[0],
"source_id": self._source_id,
**fields,
}
}
stale = {k: "" for k in existing if k not in reserved and k not in fields}
if stale:
update["$unset"] = stale
result = self._collection.update_one(query, update)
if result.matched_count == 0:
raise KeyError(f"Chunk {chunk_id} not found for source {self._source_id}")
return chunk_id
def delete_chunk(self, chunk_id):
try:
from bson.objectid import ObjectId
+53 -1
View File
@@ -637,7 +637,7 @@ class PGVectorStore(BaseVectorStore):
select_query = f"""
SELECT id, {self._text_column}, {self._metadata_column}
FROM {self._table_name}
WHERE source_id = %s;
WHERE source_id = %s ORDER BY id;
"""
cursor.execute(select_query, (self._source_id,))
results = cursor.fetchall()
@@ -704,6 +704,58 @@ class PGVectorStore(BaseVectorStore):
finally:
cursor.close()
def update_chunk(self, chunk_id: str, text: str, metadata: Dict[str, Any]) -> str:
"""Rewrite a chunk's row in place, keeping its id.
One ``UPDATE`` scoped to this source. The embedding is computed first,
so a failed embed writes nothing. ``get_chunks`` orders by id, so the
chunk also keeps its place in the list.
Args:
chunk_id: Id of the chunk to replace.
text: The chunk's new text.
metadata: The chunk's complete new metadata; ``source_id`` is
stamped on it as :meth:`add_chunk` does.
Returns:
``chunk_id``, unchanged.
Raises:
KeyError: If this source has no chunk with that id.
ValueError: If no embedding could be generated.
"""
final_metadata = dict(metadata or {})
final_metadata["source_id"] = self._source_id
embeddings = self._embedding.embed_documents([text])
if not embeddings:
raise ValueError("Could not generate embedding for chunk")
conn = self._get_connection()
cursor = conn.cursor()
try:
update_query = f"""
UPDATE {self._table_name}
SET {self._text_column} = %s, {self._vector_column} = %s, {self._metadata_column} = %s
WHERE id = %s AND source_id = %s;
"""
cursor.execute(
update_query,
(text, embeddings[0], Jsonb(final_metadata), int(chunk_id), self._source_id),
)
if cursor.rowcount == 0:
raise KeyError(f"Chunk {chunk_id} not found for source {self._source_id}")
conn.commit()
return str(chunk_id)
except Exception as e:
conn.rollback()
logging.error(f"Error updating chunk: {e}")
raise
finally:
cursor.close()
def delete_chunk(self, chunk_id: str) -> bool:
"""Delete a specific chunk by its ID"""
conn = self._get_connection()
+44
View File
@@ -206,6 +206,50 @@ class QdrantStore(BaseVectorStore):
ids = self.add_texts([text], [metadata or {}])
return ids[0]
def update_chunk(self, chunk_id: str, text: str, metadata: Dict[str, Any]) -> str:
"""Overwrite a chunk's point in place, keeping its id.
Upserting the same id replaces the vector and payload; ``scroll``
orders by point id, so the chunk also keeps its place in
:meth:`get_chunks`. The payload has the shape :meth:`add_texts` writes.
Args:
chunk_id: Id of the point to replace.
text: The chunk's new text.
metadata: The chunk's complete new metadata; ``source_id`` is
stamped on it for source scoping.
Returns:
``chunk_id``, unchanged.
Raises:
KeyError: If this source has no point with that id.
"""
records = self._client.retrieve(
collection_name=self._collection,
ids=[chunk_id],
with_payload=True,
with_vectors=False,
)
payload = (records[0].payload or {}) if records else {}
if (payload.get("metadata") or {}).get("source_id") != self._source_id:
raise KeyError(f"Chunk {chunk_id} not found for source {self._source_id}")
vector = self._embeddings.embed_documents([text])[0]
payload_metadata = dict(metadata or {})
payload_metadata["source_id"] = self._source_id
self._client.upsert(
collection_name=self._collection,
points=[
self._models.PointStruct(
id=chunk_id,
vector=vector,
payload={"page_content": text, "metadata": payload_metadata},
)
],
)
return chunk_id
def delete_chunk(self, chunk_id: str) -> bool:
"""Delete a single chunk by id."""
try:
+648 -213
View File
File diff suppressed because it is too large. Load diff
+59 -61
View File
@@ -7,22 +7,53 @@ import unusedImports from 'eslint-plugin-unused-imports';
import prettier from 'eslint-plugin-prettier';
import globals from 'globals';
import { cardSurfaceSelectors } from './eslint/card-surfaces.js';
import { everywhereSelectors, pageSelectors } from './eslint/design-rules.js';
// Selectors every file gets: viewport heights, focus return, card surfaces.
// On iOS Safari vh (and h-screen, which is 100vh) is the viewport with the
// toolbars hidden, so a vh height overflows the visible screen. w-screen is
// vw, which the toolbars don't change, so it stays allowed.
const baseSyntaxRules = [
...['Literal[value=/\\dvh\\b/]', 'TemplateElement[value.raw=/\\dvh\\b/]'].map(
(selector) => ({
selector,
message:
'vh is the toolbar-hidden viewport on iOS Safari. Use dvh for the app shell and caps on things that pop up, svh for a fixed-size panel in a page that scrolls, or max-h-sheet for a bottom sheet. See DESIGN.md.',
}),
),
...[
'Literal[value=/(^|[\\s:])(min-|max-)?h-screen\\b/]',
'TemplateElement[value.raw=/(^|[\\s:])(min-|max-)?h-screen\\b/]',
].map((selector) => ({
selector,
message:
"Tailwind's screen heights (h-/min-h-/max-h-screen) are the toolbar-hidden viewport on iOS Safari, taller than the visible screen. Use h-dvh / min-h-dvh, or svh for a fixed panel. See DESIGN.md.",
})),
// Modal, Sheet and DialogContent return focus to what had it on open
// (ui/use-focus-return.ts). A hand-rolled return focuses the trigger
// even after a tap, which lights its ring on iOS.
{
selector: 'JSXAttribute[name.name="onCloseAutoFocus"]',
message:
'Modal, Sheet and DialogContent already return focus to what had it on open (ui/use-focus-return.ts). Don\'t hand-roll onCloseAutoFocus. See DESIGN.md "Focus return".',
},
// Nothing on a filled tile repeats its muted fill (DESIGN.md "Card
// surfaces").
...cardSurfaceSelectors,
];
export default [
{
ignores: [
'node_modules/',
'dist/',
'prettier.config.cjs',
'.eslintrc.cjs',
'env.d.ts',
'public/',
'assets/',
'vite-env.d.ts',
'.prettierignore',
'package-lock.json',
'package.json',
'postcss.config.cjs',
'tailwind.config.cjs',
'tsconfig.json',
'tsconfig.node.json',
'vite.config.ts',
@@ -84,35 +115,14 @@ export default [
],
},
],
// On iOS Safari vh (and h-screen, which is 100vh) is the viewport with
// the toolbars hidden, so a vh height overflows the visible screen.
// w-screen is vw, which the toolbars don't change, so it stays allowed.
// DESIGN.md rules a selector can check: viewport heights, focus return
// and card surfaces (baseSyntaxRules above), plus the class and markup
// rules in eslint/design-rules.js.
'no-restricted-syntax': [
'error',
...[
'Literal[value=/\\dvh\\b/]',
'TemplateElement[value.raw=/\\dvh\\b/]',
].map((selector) => ({
selector,
message:
'vh is the toolbar-hidden viewport on iOS Safari. Use dvh for the app shell and caps on things that pop up, svh for a fixed-size panel in a page that scrolls, or max-h-sheet for a bottom sheet. See DESIGN.md.',
})),
...[
'Literal[value=/(^|[\\s:])(min-|max-)?h-screen\\b/]',
'TemplateElement[value.raw=/(^|[\\s:])(min-|max-)?h-screen\\b/]',
].map((selector) => ({
selector,
message:
"Tailwind's screen heights (h-/min-h-/max-h-screen) are the toolbar-hidden viewport on iOS Safari, taller than the visible screen. Use h-dvh / min-h-dvh, or svh for a fixed panel. See DESIGN.md.",
})),
// Modal, Sheet and DialogContent return focus to what had it on open
// (ui/use-focus-return.ts). A hand-rolled return focuses the trigger
// even after a tap, which lights its ring on iOS.
{
selector: 'JSXAttribute[name.name="onCloseAutoFocus"]',
message:
'Modal, Sheet and DialogContent already return focus to what had it on open (ui/use-focus-return.ts). Don\'t hand-roll onCloseAutoFocus. See DESIGN.md "Focus return".',
},
...baseSyntaxRules,
...everywhereSelectors,
...pageSelectors,
],
// Design-system rules (@shadcn/lint). Tokens, variants and the
// approved exceptions are documented in DESIGN.md.
@@ -205,23 +215,7 @@ export default [
allow: ['layout'],
message: {
default:
'"{{className}}" is not allowed on <{{component}}>: use Alert variant (default, destructive, success, warning, info) from {{file}}.',
},
},
{
pattern: '^Checkbox$',
allow: ['layout'],
message: {
default:
'"{{className}}" is not allowed on <Checkbox>: use size from {{file}}.',
},
},
{
pattern: '^Dropzone$',
allow: ['layout'],
message: {
default:
'"{{className}}" is not allowed on <Dropzone>: use size (default, compact); colours, border and radius follow the drag state in {{file}}.',
'"{{className}}" is not allowed on <{{component}}>: use Alert variant (default, neutral, success, warning, info, destructive) from {{file}}.',
},
},
{
@@ -248,14 +242,6 @@ export default [
pattern: '^Spinner$',
allow: ['layout', 'color'],
},
{
pattern: '^Progress$',
allow: ['layout'],
},
{
pattern: '^(Toast|ToastViewport)$',
allow: ['layout'],
},
{
pattern: '^(ToastContent|ToastItem|ToastFooter)$',
allow: ['layout', 'spacing'],
@@ -267,10 +253,6 @@ export default [
pattern: '^MessageScroller(Viewport|Content)$',
allow: ['layout', 'spacing'],
},
{
pattern: '^OptionCard$',
allow: ['layout'],
},
{
pattern: '^(CardHeader|CardContent|CardFooter|CardAction)$',
allow: ['layout', 'spacing', 'gap-*', 'bg-muted/*'],
@@ -334,6 +316,22 @@ export default [
rules: {
'shadcn/no-restyle': 'off',
'no-restricted-imports': 'off',
// ui/ defines the parts the page rules ask for (the table, the
// checkbox, the floating label's transition-all), so only the
// everywhere rules apply here.
'no-restricted-syntax': [
'error',
...baseSyntaxRules,
...everywhereSelectors,
],
},
},
{
// Tests hold class strings as fixtures (often asserting their absence),
// so the class-token rules don't apply to them.
files: ['src/**/*.test.{ts,tsx}'],
rules: {
'no-restricted-syntax': ['error', ...baseSyntaxRules],
},
},
{
+4
View File
@@ -0,0 +1,4 @@
export declare const cardSurfaceSelectors: {
selector: string;
message: string;
}[];
+39
View File
@@ -0,0 +1,39 @@
// Card surface rule (DESIGN.md "Card surfaces"): a tile is `filled`, and
// nothing on a tile repeats its muted fill, or it disappears into it. The
// neutral Badge and the muted Avatar are `bg-muted-foreground/15`, a tint
// that shows on muted, so they are fine; a literal `bg-muted` is not. These
// are `no-restricted-syntax` entries; they see children written in the same
// JSX tree as the `<Card variant="filled">`, not ones a child component
// renders.
// `variant="filled"` or `variant={'filled'}`.
const FILLED_VARIANT =
'JSXAttribute[name.name="variant"]:matches([value.value="filled"], [value.expression.value="filled"])';
const FILLED_CARD = `JSXElement:has(> JSXOpeningElement[name.name="Card"]:has(> ${FILLED_VARIANT}))`;
// `bg-muted` or `bg-muted/NN`, with any variant prefix, but not
// `bg-muted-foreground`.
const BG_MUTED = '/(^|[\\s:])bg-muted([^-a-z]|$)/';
/** @type {{ selector: string, message: string }[]} */
export const cardSurfaceSelectors = [
{
selector: `${FILLED_CARD} JSXElement > JSXOpeningElement[name.name="Card"] > ${FILLED_VARIANT}`,
message:
'A filled Card inside a filled Card is a fill on a fill. A well inside a tile needs a panel around it, or none. See DESIGN.md "Card surfaces".',
},
...[
`${FILLED_CARD} JSXElement JSXAttribute[name.name="className"] Literal[value=${BG_MUTED}]`,
`${FILLED_CARD} JSXElement JSXAttribute[name.name="className"] TemplateElement[value.raw=${BG_MUTED}]`,
].map((selector) => ({
selector,
message:
'bg-muted inside a filled tile is a fill on a fill and disappears. Drop the fill (use a Separator or border-border for structure). See DESIGN.md "Card surfaces".',
})),
{
selector: `${FILLED_CARD} JSXOpeningElement[name.name="Skeleton"]:not(:has(JSXAttribute[name.name="surface"]))`,
message:
'Skeleton bars on a filled tile need surface="muted", or they vanish into the fill. See DESIGN.md "Skeleton".',
},
];
+3
View File
@@ -0,0 +1,3 @@
type Selector = { selector: string; message: string };
export declare const everywhereSelectors: Selector[];
export declare const pageSelectors: Selector[];
+92
View File
@@ -0,0 +1,92 @@
// Class and markup rules from DESIGN.md that a `no-restricted-syntax`
// selector can check. `everywhereSelectors` apply to ui/ too; `pageSelectors`
// are about app code composing ui/ parts, so ui/ (which defines those parts)
// is exempt from them.
// A class token in a string: start of string, a space or a variant colon
// before it; a space or the end after it.
const cls = (body) => `/(^|[\\s:])${body}(\\s|$)/`;
const inStrings = (regex, message) =>
[`Literal[value=${regex}]`, `TemplateElement[value.raw=${regex}]`].map(
(selector) => ({ selector, message }),
);
/** @type {{ selector: string, message: string }[]} */
export const everywhereSelectors = [
...inStrings(
cls('break-all'),
'break-all splits ordinary words mid-word. Use wrap-break-word for prose and wrap-anywhere for a long token (URL, id, email). See DESIGN.md "Typography roles".',
),
...inStrings(
cls('rounded'),
'Bare `rounded` is a fixed 4px, outside the radius scale. Use rounded-sm (6px, bars), rounded-md (8px) or the role radius in DESIGN.md "Radius by role".',
),
...inStrings(
'/(^|[\\s:])(min|max)-\\[|\\[@media/',
'No custom breakpoints (min-[…], max-[…], [@media…]). Phone / desktop is lg; sm and md only reflow content. See DESIGN.md "Breakpoints".',
),
...inStrings(
'/(^|[\\s:])-?z-(?!(0|10|20|50|200|auto)(\\s|$))/',
'z-index comes from the stacking layers: z-10 sticky headers, z-20 in-page floating chrome, z-50 overlays, z-200 portalled floating lists. See DESIGN.md "Stacking".',
),
...inStrings(
'/(^|[\\s:-])(focus|focus-visible|focus-within):ring-2(\\s|$)/',
'The focus ring is ring-3 ring-ring/50 on focus-visible (fields: focus-within on a frame the same way). ring-2 is only for selection rings. See DESIGN.md "Focus ring".',
),
{
selector:
'ImportDeclaration[source.value="lucide-react"] > ImportSpecifier[imported.name=/^(Loader|Loader2|LoaderCircle)$/]',
message:
'A loader is <Spinner> (ui/spinner) or <LoadingState>, never a lucide loader icon. See DESIGN.md "Spinner and Skeleton".',
},
];
/** @type {{ selector: string, message: string }[]} */
export const pageSelectors = [
...inStrings(
cls('transition-all'),
'Transition only the property that changes: transition-colors by default, transition-transform for chevrons, transition-shadow for a ring. See DESIGN.md "Motion".',
),
...inStrings(
'/(^|[\\s:-])hover:scale-(?!x-|y-)/',
'Hover is a fill, border or text-colour change, never a scale. See DESIGN.md "Motion".',
),
...inStrings(
'/(^|[\\s:])bg-primary\\u002f10(\\s|$)/',
'The brand soft fill is bg-secondary text-secondary-foreground, not bg-primary/10. See DESIGN.md "Colour tokens".',
),
{
selector:
'JSXOpeningElement[name.name="input"] > JSXAttribute[name.name="type"][value.value="checkbox"]',
message:
'A checkbox is <Checkbox> (ui/checkbox); native boxes take the OS accent and ignore dark mode. See DESIGN.md "Checkbox".',
},
{
selector:
'JSXOpeningElement[name.name="Button"] > JSXAttribute[name.name="title"]',
message:
'An icon-only button is <IconButton label> (its tooltip is the label); a labelled Button needs no title. See DESIGN.md "Tooltip and IconButton".',
},
...[
'JSXOpeningElement[name.name="SheetContent"] > JSXAttribute[name.name="className"] Literal[value=/(^|[\\s:])(max-)?w-/]',
'JSXOpeningElement[name.name="SheetContent"] > JSXAttribute[name.name="className"] TemplateElement[value.raw=/(^|[\\s:])(max-)?w-/]',
].map((selector) => ({
selector,
message:
'A drawer\'s width comes from SheetContent size (default 384px, detail 576px, wide 800px), never a w-* or max-w-* class. See DESIGN.md "Modal, not Dialog".',
})),
{
selector: 'JSXOpeningElement[name.name="table"]',
message:
'Every table is ui/table (Table, TableHead, TableRow…), never a raw <table>. See DESIGN.md "Table".',
},
...[
'JSXOpeningElement[name.name="a"] > JSXAttribute[name.name="className"] Literal[value=/(^|[\\s:])(underline|text-primary)(\\s|$)/]',
'JSXOpeningElement[name.name="a"] > JSXAttribute[name.name="className"] TemplateElement[value.raw=/(^|[\\s:])(underline|text-primary)(\\s|$)/]',
].map((selector) => ({
selector,
message:
'A link is <Button variant="link" size="inline" asChild> around the <a>, never a raw <a> styled as a link. See DESIGN.md "Button".',
})),
];
+10 -6
View File
@@ -1,6 +1,8 @@
#!/usr/bin/env node
/**
* Summarise @shadcn/lint warnings from ESLint JSON output.
* Summarise design-lint violations from ESLint JSON output: the @shadcn/lint
* rules and the DESIGN.md selectors under no-restricted-syntax
* (eslint/design-rules.js, eslint/card-surfaces.js, viewport heights).
*
* npm run lint:design rules × counts, then the 25 worst files
* npm run lint:design -- --rule no-raw-colors files for one rule
@@ -26,10 +28,12 @@ const rows = [];
for (const file of results) {
const rel = file.filePath.replace(`${process.cwd()}/`, '');
for (const m of file.messages) {
if (!m.ruleId?.startsWith('shadcn/')) continue;
const design =
m.ruleId?.startsWith('shadcn/') || m.ruleId === 'no-restricted-syntax';
if (!design) continue;
rows.push({
file: rel,
rule: m.ruleId.slice(7),
rule: m.ruleId.replace(/^shadcn\//, ''),
line: m.line,
msg: m.message,
});
@@ -47,7 +51,7 @@ if (onlyFile) {
const mine = rows.filter(
(r) => r.file === onlyFile || r.file.endsWith(onlyFile),
);
console.log(`${mine.length} design warnings in ${onlyFile}\n`);
console.log(`${mine.length} design violations in ${onlyFile}\n`);
for (const r of mine.sort((a, b) => a.line - b.line)) {
console.log(
`${pad(r.line, 5)} ${r.rule}\n ${r.msg.split(' See frontend/DESIGN.md')[0]}\n`,
@@ -55,14 +59,14 @@ if (onlyFile) {
}
} else if (onlyRule) {
const mine = rows.filter((r) => r.rule === onlyRule);
console.log(`${mine.length} warnings for shadcn/${onlyRule}\n`);
console.log(`${mine.length} violations of ${onlyRule}\n`);
const byFile = new Map();
for (const r of mine) byFile.set(r.file, (byFile.get(r.file) ?? 0) + 1);
for (const [f, n] of [...byFile.entries()].sort((a, b) => b[1] - a[1]))
console.log(`${pad(n, 5)} ${f}`);
} else {
console.log(
`${rows.length} design warnings in ${new Set(rows.map((r) => r.file)).size} files\n`,
`${rows.length} design violations in ${new Set(rows.map((r) => r.file)).size} files\n`,
);
console.log('By rule:');
for (const [rule, n] of count('rule')) console.log(`${pad(n, 5)} ${rule}`);
+5 -2
View File
@@ -40,7 +40,7 @@ import {
import ConversationTile from './conversation/ConversationTile';
import { useMediaQuery } from './hooks';
import useTokenAuth from './hooks/useTokenAuth';
import { cn } from './lib/utils';
import { cn, overlayScrim } from './lib/utils';
import ConfirmationModal from './modals/ConfirmationModal';
import JWTModal from './modals/JWTModal';
import SearchConversationsModal from './modals/SearchConversationsModal';
@@ -402,7 +402,10 @@ export default function Navigation({ navOpen, setNavOpen }: NavigationProps) {
<>
{isMobile && navOpen && (
<div
className="fixed inset-0 z-20 bg-black opacity-50 transition-opacity duration-300"
className={cn(
'animate-in fade-in-0 fixed inset-0 z-20',
overlayScrim,
)}
onClick={() => setNavOpen(false)}
/>
)}
+73
View File
@@ -0,0 +1,73 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock('react-redux', () => ({ useSelector: () => 'token' }));
vi.mock('../api/services/userService', () => ({
default: {
getAgent: () =>
Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
id: 'a1',
name: 'Carrier FAQ',
last_used_at: null,
}),
}),
},
}));
vi.mock('../settings/Analytics', () => ({
default: () => <div data-testid="analytics" />,
}));
vi.mock('../settings/Logs', () => ({
default: () => <div data-testid="logs" />,
}));
vi.mock('./components/GuardrailEvents', () => ({ default: () => null }));
vi.mock('../navigation/SectionPageHeader', () => ({
CurrentSectionHeader: () => <h1>Logs</h1>,
}));
vi.mock('../navigation/SectionPills', () => ({ default: () => null }));
import AgentLogs from './AgentLogs';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
describe('AgentLogs', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
// A3: the same toolbar as Overview and Schedules, the agent in its byline.
it('names the agent in the shared agent toolbar', async () => {
await act(async () => {
root.render(
<MemoryRouter initialEntries={['/agents/manage/logs/a1']}>
<Routes>
<Route
path="/agents/manage/logs/:agentId"
element={<AgentLogs />}
/>
</Routes>
</MemoryRouter>,
);
});
const toolbar = container.querySelector('[data-slot="page-toolbar"]')!;
expect(toolbar.textContent).toContain('Carrier FAQ');
expect(toolbar.textContent).toContain('agents.logs.noUsageHistory');
expect(toolbar.querySelector('[data-slot="separator"]')).not.toBeNull();
});
});
+9 -19
View File
@@ -7,8 +7,8 @@ import userService from '../api/services/userService';
import { selectToken } from '../preferences/preferenceSlice';
import Analytics from '../settings/Analytics';
import Logs from '../settings/Logs';
import { formatDateTime } from '../utils/dateTimeUtils';
import SectionShell from '../navigation/SectionShell';
import AgentPageToolbar, { LastUsedMeta } from './components/AgentPageToolbar';
import GuardrailEvents from './components/GuardrailEvents';
import { Agent } from './types';
@@ -39,26 +39,16 @@ export default function AgentLogs() {
}, [agentId, token]);
return (
<SectionShell pills>
<div className="flex flex-col gap-3">
{agent && (
<div className="flex flex-col gap-1">
<p className="text-foreground">{agent.name}</p>
<p className="text-muted-foreground text-xs">
{agent.last_used_at
? t('agents.logs.lastUsedAt') +
' ' +
formatDateTime(agent.last_used_at)
: t('agents.logs.noUsageHistory')}
</p>
</div>
)}
</div>
<SectionShell>
{agent && (
<AgentPageToolbar
name={agent.name}
meta={<LastUsedMeta lastUsedAt={agent.last_used_at} />}
/>
)}
{agentId && (
<>
<div className="mt-8">
<Analytics agentId={agentId} />
</div>
<Analytics agentId={agentId} />
<GuardrailEvents agentId={agentId} />
<div className="mt-8">
<Logs
@@ -53,4 +53,52 @@ describe('AgentPageHeader sub-nav', () => {
expect(tabs[0].tagName).toBe('A');
expect(tabs[0].getAttribute('data-active')).not.toBe('true');
});
it('makes the current crumb a button with the avatar and a chevron that opens the details', () => {
const onNameClick = vi.fn();
act(() => {
root.render(
<MemoryRouter>
<AgentPageHeader
agentId="a1"
agentName="Helpdesk Triage"
currentPage="overview"
onNameClick={onNameClick}
status={<span data-testid="status">Published</span>}
/>
</MemoryRouter>,
);
});
const crumb = container.querySelector(
'button[aria-haspopup="dialog"]',
) as HTMLButtonElement;
expect(crumb).not.toBeNull();
expect(crumb.getAttribute('data-variant')).toBe('ghost');
expect(crumb.getAttribute('data-size')).toBe('sm');
expect(crumb.querySelector('img')).not.toBeNull();
expect(crumb.querySelector('.lucide-chevron-down')).not.toBeNull();
expect(crumb.textContent).toContain('Helpdesk Triage');
expect(container.querySelector('[data-testid="status"]')).not.toBeNull();
act(() => crumb.click());
expect(onNameClick).toHaveBeenCalledTimes(1);
});
it('hides the tabs for a workflow that has no id yet', () => {
act(() => {
root.render(
<MemoryRouter>
<AgentPageHeader
agentName="New workflow"
currentPage="overview"
onNameClick={vi.fn()}
/>
</MemoryRouter>,
);
});
expect(
container.querySelector(
'nav[aria-label="agents.pageHeader.subnavAriaLabel"]',
),
).toBeNull();
});
});
+115 -64
View File
@@ -1,4 +1,5 @@
import { useMemo } from 'react';
import { ChevronDown } from 'lucide-react';
import { type ReactNode, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
@@ -10,6 +11,7 @@ import {
BreadcrumbPage,
BreadcrumbSeparator,
} from '@/components/ui/breadcrumb';
import { Avatar } from '@/components/ui/avatar';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
@@ -36,13 +38,26 @@ type AgentPageHeaderProps = {
* (e.g. the workflow builder's fixed toolbar) to avoid a double rule.
*/
inline?: boolean;
/** The agent's avatar URL; the robot is drawn when it's empty. */
agentImage?: string;
/**
* Makes the current crumb a button (avatar, name, chevron) that opens the
* agent's details. Only with `currentPage="overview"`.
*/
onNameClick?: () => void;
/** A status Badge placed after the crumbs. */
status?: ReactNode;
};
/**
* Shared chrome for the agent sub-pages (Overview/Edit, Logs, Schedules).
*
* Top: shadcn Breadcrumb (`Agents > <agent name> > <current page>`).
* Bottom: underline-style sub-nav linking between the agent's sub-pages.
* The workflow builder's toolbar chrome: a Breadcrumb (`Agents > <agent
* name> > <current page>`), an optional status Badge, and underline tab links
* to the agent's Overview, Logs and Schedules (hidden until the agent has an
* id). The builder is full-screen with no sidebar, so it needs its own way
* between them. With `onNameClick` the current crumb is the agent's avatar,
* name and a chevron in a `ghost sm` Button that opens the details, like the
* phone top bar's chat title. Section pages use
* `components/AgentPageToolbar` and the sidebar instead.
*/
export default function AgentPageHeader({
agentId,
@@ -51,6 +66,9 @@ export default function AgentPageHeader({
currentPage,
className,
inline = false,
agentImage,
onNameClick,
status,
}: AgentPageHeaderProps) {
const { t } = useTranslation();
@@ -85,83 +103,116 @@ export default function AgentPageHeader({
return (
<div
className={cn(
'flex flex-col gap-3 md:flex-row md:items-baseline md:gap-6',
// The builder only renders from lg, so one row.
'flex min-w-0 items-center gap-6',
className,
)}
>
<Breadcrumb className="shrink-0">
<BreadcrumbList className="flex-nowrap">
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link to={AGENTS_MANAGE_ROOT}>
{t('agents.pageHeader.crumbs.agents')}
</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
{currentPage === 'overview' ? (
<BreadcrumbPage title={displayName} className="w-[16ch]">
{displayName}
</BreadcrumbPage>
) : (
<div className="flex min-w-0 items-center gap-2">
<Breadcrumb className="min-w-0">
<BreadcrumbList className="flex-nowrap">
<BreadcrumbItem>
<BreadcrumbLink asChild>
<Link to={editPath} className="max-w-[40ch] truncate">
{displayName}
<Link to={AGENTS_MANAGE_ROOT}>
{t('agents.pageHeader.crumbs.agents')}
</Link>
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator />
<BreadcrumbItem>
{currentPage === 'overview' && onNameClick ? (
<Button
type="button"
variant="ghost"
size="sm"
aria-haspopup="dialog"
onClick={onNameClick}
// Button is shrink-0; shrink lets a long name truncate.
className="min-w-0 shrink"
>
<Avatar
src={agentImage}
alt=""
shape="circle"
className="shrink-0 overflow-hidden"
imgClassName="size-5 object-contain"
/>
{/* The list's muted colour would reach the name; the current crumb
is foreground, like BreadcrumbPage. */}
<span
className="text-foreground max-w-[24ch] truncate"
title={displayName}
>
{displayName}
</span>
<ChevronDown className="text-muted-foreground" aria-hidden />
</Button>
) : currentPage === 'overview' ? (
<BreadcrumbPage title={displayName} className="w-[16ch]">
{displayName}
</BreadcrumbPage>
) : (
<BreadcrumbLink asChild>
<Link to={editPath} className="max-w-[40ch] truncate">
{displayName}
</Link>
</BreadcrumbLink>
)}
</BreadcrumbItem>
{currentPage !== 'overview' && (
<>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>{currentTabLabel}</BreadcrumbPage>
</BreadcrumbItem>
</>
)}
</BreadcrumbItem>
{currentPage !== 'overview' && (
<>
<BreadcrumbSeparator />
<BreadcrumbItem>
<BreadcrumbPage>{currentTabLabel}</BreadcrumbPage>
</BreadcrumbItem>
</>
)}
</BreadcrumbList>
</Breadcrumb>
</BreadcrumbList>
</Breadcrumb>
{status}
</div>
<nav
aria-label={t('agents.pageHeader.subnavAriaLabel')}
className={cn(
'flex items-center gap-6',
// 1px baseline rule under the whole row; the active tab's 2px
// primary underline sits on top of it for the GitHub-style look.
!inline && 'border-border border-b',
)}
>
{tabs.map((tab) => {
const isActive = tab.id === currentPage;
// -mb-px lays the tab's 2px underline over the nav's 1px baseline.
if (isActive) {
{agentId && (
<nav
aria-label={t('agents.pageHeader.subnavAriaLabel')}
className={cn(
'flex items-center gap-6',
// 1px baseline rule under the whole row; the active tab's 2px
// primary underline sits on top of it for the GitHub-style look.
!inline && 'border-border border-b',
)}
>
{tabs.map((tab) => {
const isActive = tab.id === currentPage;
// -mb-px lays the tab's 2px underline over the nav's 1px baseline.
if (isActive) {
return (
<Button
key={tab.id}
asChild
variant="tab"
size="inline"
data-active
className="-mb-px"
>
<span aria-current="page">{tab.label}</span>
</Button>
);
}
return (
<Button
key={tab.id}
asChild
variant="tab"
size="inline"
data-active
className="-mb-px"
>
<span aria-current="page">{tab.label}</span>
<Link to={tab.href}>{tab.label}</Link>
</Button>
);
}
return (
<Button
key={tab.id}
asChild
variant="tab"
size="inline"
className="-mb-px"
>
<Link to={tab.href}>{tab.label}</Link>
</Button>
);
})}
</nav>
})}
</nav>
)}
</div>
);
}
+11 -11
View File
@@ -1,8 +1,9 @@
import { MessageSquare } from 'lucide-react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { EmptyState } from '@/components/ui/empty-state';
import MessageInput from '../components/MessageInput';
import ConversationMessages from '../conversation/ConversationMessages';
import { Query } from '../conversation/conversationModels';
@@ -13,7 +14,6 @@ import {
fetchPreviewAnswer,
handlePreviewAbort,
resendQuery,
resetPreview,
selectPreviewQueries,
selectPreviewStatus,
} from './agentPreviewSlice';
@@ -96,12 +96,13 @@ export default function AgentPreview() {
}
};
// Closing the drawer unmounts the preview: stop a stream in flight, but
// keep the chat. The page that hosts the drawer decides when it starts
// over (New chat, a save, leaving the page).
useEffect(() => {
dispatch(resetPreview());
return () => {
if (fetchStream.current) fetchStream.current.abort();
handlePreviewAbort();
dispatch(resetPreview());
};
}, [dispatch]);
@@ -115,13 +116,12 @@ export default function AgentPreview() {
<div className="relative h-full w-full">
<div className="scrollbar-overlay absolute inset-0 bottom-[180px] overflow-hidden px-4 pt-4 [&>div>div]:w-full! [&>div>div]:max-w-none!">
{queries.length === 0 ? (
<section className="flex h-full flex-col items-center justify-center">
<div className="bg-muted mb-2 flex size-14 shrink-0 items-center justify-center rounded-xl">
<MessageSquare className="text-muted-foreground size-6" />
</div>
<p className="text-foreground text-sm font-medium">
{t('agents.preview.emptyTitle')}
</p>
<section className="flex h-full items-center justify-center">
<EmptyState
size="sm"
illustration="none"
title={t('agents.preview.emptyTitle')}
/>
</section>
) : (
<ConversationMessages
+227 -29
View File
@@ -1,6 +1,6 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { MemoryRouter } from 'react-router-dom';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import type { MultiSelectPopoverItem } from '../components/MultiSelectPopover';
@@ -16,16 +16,26 @@ const mockState = {
prompts: [],
agentFolders: [],
},
agentPreview: { queries: [], status: 'idle' },
};
const mocks = vi.hoisted(() => {
const jsonResponse = (body: unknown, ok = true) =>
Promise.resolve({ ok, json: () => Promise.resolve(body) });
return {
jsonResponse,
dispatch: vi.fn(),
getAgent: vi.fn(() => jsonResponse({})),
createAgent: vi.fn(() => jsonResponse({ message: 'Name is taken' }, false)),
};
});
const { jsonResponse } = mocks;
vi.mock('react-redux', () => ({
useSelector: (selector: (state: unknown) => unknown) => selector(mockState),
useDispatch: () => vi.fn(),
useDispatch: () => mocks.dispatch,
}));
const jsonResponse = (body: unknown, ok = true) =>
Promise.resolve({ ok, json: () => Promise.resolve(body) });
vi.mock('../api/services/userService', () => ({
default: {
getUserTools: () =>
@@ -40,8 +50,8 @@ vi.mock('../api/services/userService', () => ({
],
}),
getAgentFolders: () => jsonResponse({ folders: [] }),
getAgent: () => jsonResponse({}),
createAgent: () => jsonResponse({ message: 'Name is taken' }, false),
getAgent: mocks.getAgent,
createAgent: mocks.createAgent,
updateAgent: () => jsonResponse({}),
deleteAgent: () => jsonResponse({}),
createPrompt: () => jsonResponse({}),
@@ -95,9 +105,22 @@ vi.mock('../modals/AgentDetailsModal', () => ({ default: () => null }));
vi.mock('../teams/ShareToTeamModal', () => ({ default: () => null }));
vi.mock('../modals/ConfirmationModal', () => ({ default: () => null }));
vi.mock('../preferences/PromptsModal', () => ({ default: () => null }));
vi.mock('../navigation/SectionPills', () => ({ default: () => null }));
vi.mock('../navigation/SectionPills', () => ({
default: () => <div data-testid="section-pills" />,
}));
vi.mock('../navigation/SectionPageHeader', () => ({
CurrentSectionHeader: () => null,
CurrentSectionHeader: ({
title,
titleAction,
}: {
title?: React.ReactNode;
titleAction?: React.ReactNode;
}) => (
<div>
{title ? <h1>{title}</h1> : null}
{titleAction}
</div>
),
}));
vi.mock('../components/FileUpload', () => ({ FileUpload: () => null }));
vi.mock('../components/SourcesPopoverFooter', () => ({ default: () => null }));
@@ -132,6 +155,9 @@ describe('NewAgent form', () => {
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
mocks.dispatch.mockClear();
mocks.getAgent.mockClear();
mocks.createAgent.mockClear();
});
const render = async () => {
@@ -182,33 +208,176 @@ describe('NewAgent form', () => {
}
});
it('titles each form panel with a SectionHeader spaced by the panel gap', async () => {
// A1a: three grouped sections instead of six one-field panels.
it('groups the form into Basics, Knowledge and behaviour, and Model', async () => {
await render();
const titles = Array.from(
container.querySelectorAll('[data-slot="section-header"] > h2'),
).map((h) => h.textContent);
expect(titles).toEqual(
expect(titles).toEqual([
'agents.form.sections.basics',
'agents.form.sections.knowledge',
'agents.form.sections.model',
]);
const basics = Array.from(
container.querySelectorAll('[data-slot="section-header"]'),
).find((h) => h.textContent === 'agents.form.sections.basics')!;
expect(basics.parentElement!.className.split(' ')).toEqual(
expect.arrayContaining(['flex', 'flex-col', 'gap-5']),
);
});
// On a phone the avatar sits beside Name and Description spans the row;
// from sm the avatar spans both rows beside the fields.
it('lays Basics out as avatar beside Name, Description full width on a phone', async () => {
await render();
const name = container.querySelector(
'input[placeholder="agents.form.placeholders.agentName"]',
)!;
const grid = name.closest('.grid')!;
expect(grid.className).toContain('grid-cols-[auto_1fr]');
const description = container
.querySelector(
'textarea[placeholder="agents.form.placeholders.describeAgent"]',
)!
.closest('[data-slot="form-field"]')!;
expect(description.className).toContain('col-span-2');
expect(description.className).toContain('sm:col-start-2');
});
it('labels every picker with a floating label', async () => {
await render();
const labels = Array.from(
container.querySelectorAll('[data-slot="form-field-label"]'),
).map((l) => l.textContent);
expect(labels).toEqual(
expect.arrayContaining([
'agents.form.sections.meta',
'agents.form.sections.source',
'agents.form.labels.name',
'agents.form.labels.description',
'agents.form.labels.sources',
'agents.form.sections.tools',
'agents.form.sections.agentType',
'agents.form.sections.models',
]),
);
const meta = Array.from(
container.querySelectorAll('[data-slot="section-header"]'),
).find((h) => h.textContent === 'agents.form.sections.meta')!;
expect(meta.parentElement!.className.split(' ')).toEqual(
expect.arrayContaining(['flex', 'flex-col', 'gap-5']),
});
it('puts Sources beside Tools in a two-up field grid', async () => {
await render();
const [sources, tools] = Array.from(
container.querySelectorAll('[data-testid="picker"] > button'),
);
expect((meta.nextElementSibling as HTMLElement).className).not.toContain(
'mt-5',
const grid = sources.closest('.grid')!;
expect(grid.className).toContain('sm:grid-cols-2');
expect(grid.contains(tools)).toBe(true);
});
it('titles the new-agent page and puts its actions in the agent toolbar', async () => {
await render();
expect(container.querySelector('h1')?.textContent).toBe('agents.newAgent');
const toolbar = container.querySelector('[data-slot="page-toolbar"]')!;
expect(toolbar.textContent).toContain('agents.form.byline.new');
expect(toolbar.contains(buttonByText('agents.form.buttons.publish'))).toBe(
true,
);
});
// Decision 64: the Advanced header is a section-toggle with a leading
// lucide chevron; the panel draws the focus ring.
// Before publishing, Preview can only say "Publish to preview", so it is a
// ⋯ item beside the title rather than a toolbar button.
it('drops the New agent title once the agent is saved', async () => {
await render();
mocks.createAgent.mockImplementationOnce(() =>
jsonResponse({ id: 'agent-1' }),
);
await act(async () =>
buttonByText('agents.form.buttons.saveDraft').click(),
);
expect(container.querySelector('h1')?.textContent).not.toBe(
'agents.newAgent',
);
});
// The preview talks to the saved agent; Redux must hold that snapshot, not
// the unsaved form, or the preview picks up an unsaved model.
it('keeps the preview on the saved agent while editing a published one', async () => {
mocks.getAgent.mockImplementationOnce(() =>
jsonResponse({
id: 'agent-1',
name: 'Saved name',
description: 'Saved description',
status: 'published',
agent_type: 'classic',
prompt_id: 'default',
}),
);
await act(async () => {
root.render(
<MemoryRouter initialEntries={['/agents/edit/agent-1']}>
<Routes>
<Route
path="/agents/edit/:agentId"
element={<NewAgent mode="edit" />}
/>
</Routes>
</MemoryRouter>,
);
});
const name = container.querySelector<HTMLInputElement>(
'input[placeholder="agents.form.placeholders.agentName"]',
)!;
await act(async () => setNativeValue(name, 'Unsaved name'));
const pushed = mocks.dispatch.mock.calls
.map(([action]) => action)
.filter((a) => a?.type === 'preference/setSelectedAgent' && a.payload);
expect(pushed.length).toBeGreaterThan(0);
for (const action of pushed) {
expect(action.payload.name).toBe('Saved name');
}
});
it('offers Preview from the title-row menu until the agent is published', async () => {
await render();
const toolbar = container.querySelector('[data-slot="page-toolbar"]')!;
expect(
Array.from(toolbar.querySelectorAll('button')).some((b) =>
b.textContent?.includes('agents.form.sections.preview'),
),
).toBe(false);
const menu = container.querySelector<HTMLButtonElement>(
'button[aria-label="agents.form.buttons.moreActions"]',
)!;
expect(menu.getAttribute('data-size')).toBe('icon');
// Beside the page title, not in the toolbar row.
expect(menu.closest('[data-slot="page-toolbar"]')).toBeNull();
expect(menu.parentElement!.querySelector('h1')).not.toBeNull();
await act(async () => {
menu.dispatchEvent(
new PointerEvent('pointerdown', { bubbles: true, button: 0 }),
);
});
const preview = Array.from(
document.querySelectorAll<HTMLElement>('[role="menuitem"]'),
).find((item) => item.textContent === 'agents.form.sections.preview')!;
await act(async () => preview.click());
const sheet = document.querySelector('[data-slot="sheet-content"]')!;
expect(sheet.textContent).toContain('agents.form.preview.publishTitle');
});
it('stretches the main button on a phone', async () => {
await render();
const publish = buttonByText('agents.form.buttons.publish');
expect(publish.className).toContain('flex-1');
expect(publish.className).toContain('sm:flex-none');
});
// The sidebar (and the agent card's menu) already switch between an
// agent's pages, so the pages carry no pill row.
it('has no destination pills', async () => {
await render();
expect(container.querySelector('[data-testid="section-pills"]')).toBeNull();
});
it('renders the Advanced header as a section-toggle', async () => {
await render();
const toggle = buttonByText('agents.form.sections.advanced');
@@ -221,7 +390,7 @@ describe('NewAgent form', () => {
expect(toggle.querySelectorAll('svg')).toHaveLength(1);
expect(toggle.querySelector('h2')).toBeNull();
expect(toggle.parentElement!.tagName).toBe('H2');
const panel = toggle.closest('.bg-card')!;
const panel = toggle.closest('[data-slot="card"]')!;
expect(panel.className).toContain(
'has-[[data-variant=section-toggle]:focus-visible]:ring-3',
);
@@ -265,11 +434,14 @@ describe('NewAgent form', () => {
it('renders Save draft and Publish as pill button variants', async () => {
await render();
// A4: one purple button per page; every header button is field pill.
const draft = buttonByText('agents.form.buttons.saveDraft');
expect(draft.getAttribute('data-variant')).toBe('outline-primary');
expect(draft.getAttribute('data-variant')).toBe('outline');
expect(draft.getAttribute('data-size')).toBe('field');
expect(draft.getAttribute('data-shape')).toBe('pill');
const publish = buttonByText('agents.form.buttons.publish');
expect(publish.getAttribute('data-variant')).toBe('default');
expect(publish.getAttribute('data-size')).toBe('field');
expect(publish.getAttribute('data-shape')).toBe('pill');
expect(publish.disabled).toBe(true);
});
@@ -283,6 +455,7 @@ describe('NewAgent form', () => {
await act(async () => setNativeValue(name, 'Support bot'));
const cancel = buttonByText('agents.form.buttons.cancel');
expect(cancel.getAttribute('data-variant')).toBe('ghost');
expect(cancel.getAttribute('data-size')).toBe('field');
expect(cancel.getAttribute('data-shape')).toBe('pill');
});
@@ -327,12 +500,37 @@ describe('NewAgent form', () => {
expect(valid.querySelector('svg.lucide-circle-check')).not.toBeNull();
});
it('shows the preview placeholder illustration as decorative images', async () => {
// Card surfaces: the form is a place, so each section is a subtle panel
// straight on the page, with no muted panel around the form.
it('draws each form section as a subtle panel on the page', async () => {
await render();
const images = container.querySelectorAll('img[alt=""]');
expect(images).toHaveLength(2);
for (const img of images) {
expect(img.getAttribute('aria-hidden')).toBe('true');
const titles = Array.from(
container.querySelectorAll<HTMLElement>('[data-slot="section-header"]'),
);
expect(titles.length).toBeGreaterThan(0);
for (const title of titles) {
const panel = title.closest<HTMLElement>('[data-slot="card"]')!;
expect(panel.dataset.variant).toBe(
title.closest('[data-tone="destructive"]')
? panel.dataset.variant
: 'subtle',
);
}
const advanced = buttonByText('agents.form.sections.advanced');
const panel = advanced.closest<HTMLElement>('[data-slot="card"]')!;
expect(panel.dataset.variant).toBe('subtle');
expect(panel.dataset.padding).toBe('lg');
expect(container.querySelector('.bg-muted.rounded-2xl')).toBeNull();
});
it('notches floating labels on the page background', async () => {
await render();
const labels = Array.from(
container.querySelectorAll<HTMLElement>('[data-slot="form-field-label"]'),
);
expect(labels.length).toBeGreaterThan(0);
for (const label of labels) {
expect(label.className).toContain('bg-background');
}
});
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,22 @@
import { renderToStaticMarkup } from 'react-dom/server';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
import SharedAgentCard from './SharedAgentCard';
describe('SharedAgentCard', () => {
// Card surfaces: a summary of one agent is a thing, so it is a filled tile.
it('is a filled tile', () => {
const html = renderToStaticMarkup(
<SharedAgentCard
agent={
{ id: 'a1', name: 'Carrier FAQ', description: 'Answers' } as never
}
/>,
);
expect(html).toContain('data-slot="card"');
expect(html).toContain('data-variant="filled"');
});
});
+1 -1
View File
@@ -27,7 +27,7 @@ export default function SharedAgentCard({
Object.keys(agent.shared_metadata).length > 0;
return (
<Card
variant="subtle"
variant="filled"
padding="lg"
className="w-full max-w-[720px] sm:w-fit sm:min-w-[480px]"
>
@@ -0,0 +1,67 @@
import { renderToStaticMarkup } from 'react-dom/server';
import AgentPageToolbar from './AgentPageToolbar';
describe('AgentPageToolbar', () => {
it('puts the agent byline beside the actions and closes with a rule', () => {
const html = renderToStaticMarkup(
<AgentPageToolbar
name="Carrier onboarding FAQ"
status={<span data-testid="status">Published</span>}
meta="Last used at 21 Sep"
actions={<button type="button">Save</button>}
/>,
);
expect(html).toContain('data-slot="page-toolbar"');
expect(html).toContain('Carrier onboarding FAQ');
expect(html).toContain('data-testid="status"');
expect(html).toContain('Last used at 21 Sep');
expect(html).toContain('<button type="button">Save</button>');
expect(html).toContain('data-slot="separator"');
});
// On a phone, a status badge fills the first line: the meta takes a line
// of its own there, without a dangling separator.
it('moves the meta to its own line on a phone when there is a status', () => {
const html = renderToStaticMarkup(
<AgentPageToolbar
name="FAQ"
status={<span>Published</span>}
meta="Last used"
/>,
);
expect(html).toContain(
'<span aria-hidden="true" class="hidden sm:inline">·</span>',
);
expect(html).toContain(
'<span class="basis-full sm:basis-auto">Last used</span>',
);
});
it('keeps the meta on the name line when there is no status', () => {
const html = renderToStaticMarkup(
<AgentPageToolbar name="FAQ" meta="Last used" />,
);
expect(html).toContain(
'<span aria-hidden="true">·</span><span>Last used</span>',
);
});
it('keeps the row at field height when a tab has no actions', () => {
const html = renderToStaticMarkup(<AgentPageToolbar name="FAQ" />);
// A 38px spacer stands in for the actions, so the rule lands in the same
// place on every agent tab.
expect(html).toContain('h-9.5');
});
it('renders notices between the row and the rule', () => {
const html = renderToStaticMarkup(
<AgentPageToolbar name="FAQ">
<p>Save failed</p>
</AgentPageToolbar>,
);
expect(html.indexOf('Save failed')).toBeLessThan(
html.indexOf('data-slot="separator"'),
);
});
});
@@ -0,0 +1,94 @@
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import PageToolbar from '../../components/PageToolbar';
import { formatDateTime } from '../../utils/dateTimeUtils';
type AgentPageToolbarProps = {
/** The agent's name, first in the byline. */
name?: string;
/** A status Badge beside the name (Overview's Published / Draft). */
status?: ReactNode;
/** The tab's context after the name, e.g. "Last used at …". */
meta?: ReactNode;
/** The tab's buttons, on the right of the row. */
actions?: ReactNode;
/** A line of its own in place of the name byline (the new-agent form). */
intro?: ReactNode;
/** Notices under the row, above the rule (a failed save). */
children?: ReactNode;
};
/**
* The block under an agent tab's title (Overview, Logs, Schedules): a muted
* byline naming the agent, the tab's actions on the right, then a rule. The
* row keeps the field height when a tab has no actions, so the rule lands in
* the same place on every tab and nothing jumps when switching.
*
* Args:
* name: The agent's name.
* status: A Badge shown beside the name.
* meta: Tab context shown after the name, separated by a middle dot.
* actions: The tab's buttons.
* intro: Replaces the name byline.
* children: Notices between the row and the rule.
*/
export default function AgentPageToolbar({
name,
status,
meta,
actions,
intro,
children,
}: AgentPageToolbarProps) {
const byline = intro ?? (
<span className="inline-flex flex-wrap items-center gap-x-2 gap-y-1">
{name ? <span className="wrap-break-word">{name}</span> : null}
{status}
{meta ? (
status ? (
// With a status badge the first line is full on a phone: the meta
// takes its own line there, so no separator is left hanging.
<>
<span aria-hidden className="hidden sm:inline">
·
</span>
<span className="basis-full sm:basis-auto">{meta}</span>
</>
) : (
<>
<span aria-hidden>·</span>
<span>{meta}</span>
</>
)
) : null}
</span>
);
return (
<PageToolbar
intro={byline}
action={actions ?? <div aria-hidden className="h-9.5" />}
divider
>
{children}
</PageToolbar>
);
}
/**
* The agent tabs' usage meta: "Last used at …", or "no usage history".
*
* Args:
* lastUsedAt: The agent's `last_used_at` timestamp.
*/
export function LastUsedMeta({ lastUsedAt }: { lastUsedAt?: string | null }) {
const { t } = useTranslation();
return (
<>
{lastUsedAt
? `${t('agents.logs.lastUsedAt')} ${formatDateTime(lastUsedAt)}`
: t('agents.logs.noUsageHistory')}
</>
);
}
@@ -0,0 +1,120 @@
import { act } from 'react';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
const dispatch = vi.fn();
vi.mock('react-redux', () => ({ useDispatch: () => dispatch }));
import { createRoot, type Root } from 'react-dom/client';
import AgentPreviewSheet from './AgentPreviewSheet';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
describe('AgentPreviewSheet', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
const render = async () => {
await act(async () => {
root.render(
<AgentPreviewSheet
open
onOpenChange={() => undefined}
title="Preview"
description="Carrier onboarding FAQ"
actions={<span data-testid="action">Running</span>}
>
<div data-testid="body">Chat</div>
</AgentPreviewSheet>,
);
});
};
it('is a right Sheet at the workflow preview widths', async () => {
await render();
const content = document.querySelector<HTMLElement>(
'[data-slot="sheet-content"]',
)!;
expect(content.dataset.side).toBe('right');
expect(content.className).toContain('lg:max-w-[800px]');
expect(content.className).toContain('p-0');
});
it('titles the drawer with SheetTitle and SheetDescription', async () => {
await render();
expect(
document.querySelector('[data-slot="sheet-title"]')?.textContent,
).toBe('Preview');
expect(
document.querySelector('[data-slot="sheet-description"]')?.textContent,
).toBe('Carrier onboarding FAQ');
});
it('keeps header actions clear of the close button', async () => {
await render();
const action = document.querySelector('[data-testid="action"]')!;
const header = action.parentElement!;
// The X sits at top-2 right-2 (32px wide); pr-12 keeps 48px free.
expect(header.className).toContain('pr-12');
expect(
header.contains(document.querySelector('[data-slot="sheet-title"]')),
).toBe(true);
});
// DESIGN maps "running" to info, as schedule runs do (was primary text).
it('shows a running run as an info badge in the header', async () => {
await act(async () => {
root.render(
<AgentPreviewSheet
open
onOpenChange={() => undefined}
title="Preview"
running
>
<div />
</AgentPreviewSheet>,
);
});
const badge = document.querySelector<HTMLElement>('[data-slot="badge"]')!;
expect(badge.dataset.variant).toBe('info');
expect(badge.textContent).toBe('agents.schedules.status.running');
});
// The toast stack moves bottom-left while any agent preview is open.
it('reports whether it is open, and closed when it goes away', async () => {
dispatch.mockClear();
await render();
expect(dispatch).toHaveBeenCalledWith({
type: 'workflowPreview/setPreviewOpen',
payload: true,
});
await act(async () => root.unmount());
root = createRoot(container);
expect(dispatch).toHaveBeenLastCalledWith({
type: 'workflowPreview/setPreviewOpen',
payload: false,
});
});
it('renders the body under a separator on the sheet surface', async () => {
await render();
const body = document.querySelector('[data-testid="body"]')!;
expect(body.textContent).toBe('Chat');
expect(document.querySelector('[data-slot="separator"]')).not.toBeNull();
const content = document.querySelector('[data-slot="sheet-content"]')!;
expect(content.querySelector('.bg-card')).toBeNull();
});
});
@@ -0,0 +1,93 @@
import { type ReactNode, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import {
Sheet,
SheetContent,
SheetDescription,
SheetTitle,
} from '@/components/ui/sheet';
import { setPreviewOpen } from '../workflow/workflowPreviewSlice';
type AgentPreviewSheetProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
/** The drawer's title, e.g. "Preview". */
title: string;
/** One muted line under the title: the agent's name. */
description?: string;
/** Shows an info "Running" badge while the preview is answering. */
running?: boolean;
/** Actions at the header's right end (New chat). */
actions?: ReactNode;
/** The preview itself; fills the rest of the drawer. */
children: ReactNode;
};
/**
* The drawer an agent is previewed in, for workflow and classic agents alike:
* a right Sheet with a title row, a rule, then the preview filling the rest.
*
* Args:
* open: Whether the drawer is open.
* onOpenChange: Called with the next open state (the X, Escape, the scrim).
* title: The drawer's title.
* description: A muted line under the title.
* running: Whether the preview is answering; shows a Running badge.
* actions: Nodes at the right end of the header, clear of the close button.
* children: The preview body.
*/
export default function AgentPreviewSheet({
open,
onOpenChange,
title,
description,
running = false,
actions,
children,
}: AgentPreviewSheetProps) {
const { t } = useTranslation();
const dispatch = useDispatch();
// App moves the toast stack bottom-left while a preview drawer is open, so
// it doesn't sit over the chat. The flag lives in the workflow preview
// slice for both agent types.
useEffect(() => {
dispatch(setPreviewOpen(open));
return () => {
dispatch(setPreviewOpen(false));
};
}, [dispatch, open]);
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" size="wide" className="p-0">
<div className="flex min-h-0 flex-1 flex-col">
{/* pr-12 keeps the header clear of the close X at top-2 right-2. */}
<div className="flex items-center gap-3 px-6 pt-6 pr-12 pb-4">
<div className="flex min-w-0 flex-1 flex-col gap-1">
<SheetTitle>{title}</SheetTitle>
{description && (
<SheetDescription className="truncate" title={description}>
{description}
</SheetDescription>
)}
</div>
{running && (
<Badge variant="info">
{t('agents.schedules.status.running')}
</Badge>
)}
{actions}
</div>
<Separator />
<div className="flex min-h-0 flex-1 flex-col">{children}</div>
</div>
</SheetContent>
</Sheet>
);
}
@@ -78,6 +78,14 @@ describe('GuardrailEvents tones', () => {
container.remove();
});
// A3: the section lines up with Analytics and the log table on Logs.
it('is not indented against the sections around it', () => {
const root = document.querySelector<HTMLElement>(
'[data-testid="guardrail-events"]',
)!;
expect(root.className).not.toMatch(/(^|\s)px-/);
});
it('colours outcome badges by meaning', () => {
const variants = Array.from(
container.querySelectorAll(
@@ -127,7 +127,7 @@ export default function GuardrailEvents({ agentId }: Props) {
const tableHeadingId = React.useId();
return (
<div className="mt-8 px-4" data-testid="guardrail-events">
<div className="mt-8" data-testid="guardrail-events">
<SectionHeader
title={t('agents.guardrailEvents.heading')}
description={t('agents.guardrailEvents.description')}
@@ -179,18 +179,44 @@ describe('GuardrailsSection', () => {
expect(active.parentElement).toBe(heading.parentElement);
expect(heading.parentElement?.firstElementChild).toBe(heading);
// A place, not a thing: the section is a subtle panel on the page, and
// Card draws the ring for the section-toggle inside it.
const panel = q('guardrails-section')!;
expect(panel.contains(toggle)).toBe(true);
expect(panel.dataset.slot).toBe('card');
expect(panel.dataset.variant).toBe('subtle');
expect(panel.dataset.padding).toBe('lg');
expect(panel.className).toContain(
'has-[[data-variant=section-toggle]:focus-visible]:ring-3',
);
expect(panel.className).toContain('bg-card');
await act(async () => toggle.click());
expect(toggle.getAttribute('aria-expanded')).toBe('false');
expect(chevron.getAttribute('class')).not.toContain('rotate-90');
});
// Decision 74: the panel spaces header and body with gap-5 like the other
// form sections, and the body stacks its rows with a gap-5 flex column
// rather than per-child margins.
it('spaces the panel and its body with gap-5, not margins', async () => {
await render({ disabled: true });
const panel = q('guardrails-section')!;
expect(panel.className.split(' ')).toContain('gap-5');
expect(panel.className.split(' ')).not.toContain('gap-3');
const body = panel.children[1] as HTMLElement;
expect(body.className.split(' ')).toEqual(
expect.arrayContaining(['flex', 'flex-col', 'gap-5']),
);
// Notices, Enable row, mode, checks, block message, fail-open, timeout.
expect(body.children.length).toBeGreaterThanOrEqual(9);
for (const child of Array.from(body.children)) {
expect(child.getAttribute('class') ?? '').not.toMatch(/(^|\s)mt-\d/);
}
// The checks list keeps its own tighter gap.
const checks = q('guardrail-orphan-gone')!.parentElement!;
expect(checks.className.split(' ')).toContain('gap-3');
});
it('renders the header pills as badges in their status roles', async () => {
await render();
expect(q('guardrails-active-badge')?.dataset.slot).toBe('badge');
@@ -343,6 +369,7 @@ describe('GuardrailsSection', () => {
await render();
const card = q('guardrail-check-pii');
expect(card?.dataset.slot).toBe('card');
expect(card?.dataset.variant).toBe('subtle');
expect(card?.dataset.padding).toBe('sm');
});
@@ -216,8 +216,10 @@ export default function GuardrailsSection({
const instanceDisabled = catalog !== null && catalog.enabled === false;
return (
<div
className="bg-card has-[[data-variant=section-toggle]:focus-visible]:ring-ring/50 rounded-2xl px-6 py-3 has-[[data-variant=section-toggle]:focus-visible]:ring-3 has-[[data-variant=section-toggle]:focus-visible]:ring-inset"
<Card
variant="subtle"
padding="lg"
className="gap-5"
data-testid="guardrails-section"
>
<div className="flex flex-wrap items-center gap-2">
@@ -265,7 +267,7 @@ export default function GuardrailsSection({
</div>
{expanded && (
<div className="mt-3 pb-3">
<div className="flex flex-col gap-5">
{loadError && (
<EmptyState
tone="destructive"
@@ -286,22 +288,14 @@ export default function GuardrailsSection({
)}
{disabled && disabledNotice && (
<Alert
role="status"
className="mt-3"
data-testid="guardrails-read-only"
>
<Alert role="status" data-testid="guardrails-read-only">
<Lock aria-hidden="true" className="size-4" />
<AlertDescription>{disabledNotice}</AlertDescription>
</Alert>
)}
{instanceDisabled && (
<Alert
variant="warning"
className="mt-3"
data-testid="guardrails-instance-disabled"
>
<Alert variant="warning" data-testid="guardrails-instance-disabled">
<TriangleAlert aria-hidden="true" className="size-4" />
<AlertDescription>
{t('agents.form.guardrails.instanceDisabled')}
@@ -310,7 +304,7 @@ export default function GuardrailsSection({
)}
{floorControls.size > 0 && (
<Alert variant="info" role="status" className="mt-3">
<Alert variant="info" role="status">
<Info aria-hidden="true" className="size-4" />
<AlertDescription>
{t('agents.form.guardrails.floorNotice', {
@@ -321,7 +315,6 @@ export default function GuardrailsSection({
)}
<SettingRow
className="mt-4"
label={t('agents.form.guardrails.enable')}
description={t('agents.form.guardrails.enableDescription')}
htmlFor={enabledId}
@@ -337,45 +330,42 @@ export default function GuardrailsSection({
{config.enabled && (
<>
<div className="mt-5">
<FormField
label={t('agents.form.guardrails.mode')}
hint={
config.mode === 'monitor_only'
? t('agents.form.guardrails.monitorHint')
: undefined
<FormField
labelSurface="background"
label={t('agents.form.guardrails.mode')}
hint={
config.mode === 'monitor_only'
? t('agents.form.guardrails.monitorHint')
: undefined
}
disabled={disabled}
>
<Select
value={config.mode}
onValueChange={(mode) =>
patch({ mode: mode as GuardrailsConfig['mode'] })
}
disabled={disabled}
>
<Select
value={config.mode}
onValueChange={(mode) =>
patch({ mode: mode as GuardrailsConfig['mode'] })
}
disabled={disabled}
<SelectTrigger
className="w-full"
size="field"
shape="pill"
data-testid="guardrails-mode"
>
<SelectTrigger
className="w-full"
size="field"
shape="pill"
data-testid="guardrails-mode"
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(catalog?.modes ?? Object.keys(MODE_KEYS)).map(
(mode) => (
<SelectItem key={mode} value={mode}>
{t(MODE_KEYS[mode] ?? mode)}
</SelectItem>
),
)}
</SelectContent>
</Select>
</FormField>
</div>
<SelectValue />
</SelectTrigger>
<SelectContent>
{(catalog?.modes ?? Object.keys(MODE_KEYS)).map((mode) => (
<SelectItem key={mode} value={mode}>
{t(MODE_KEYS[mode] ?? mode)}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
<div className="mt-5">
<div>
<p className="mb-3 text-sm font-medium">
{t('agents.form.guardrails.checks')}
</p>
@@ -426,7 +416,7 @@ export default function GuardrailsSection({
</div>
<FormField
className="mt-6"
labelSurface="background"
label={t('agents.form.guardrails.blockMessage')}
hint={t('agents.form.guardrails.blockMessageDescription')}
disabled={disabled}
@@ -442,7 +432,6 @@ export default function GuardrailsSection({
</FormField>
<SettingRow
className="mt-6"
label={t('agents.form.guardrails.failOpen')}
description={t('agents.form.guardrails.failOpenDescription')}
htmlFor={failOpenId}
@@ -457,7 +446,7 @@ export default function GuardrailsSection({
</SettingRow>
<FormField
className="mt-5"
labelSurface="background"
label={t('agents.form.guardrails.timeout')}
disabled={disabled}
>
@@ -476,7 +465,7 @@ export default function GuardrailsSection({
)}
</div>
)}
</div>
</Card>
);
}
@@ -597,7 +586,11 @@ function CheckCard({
);
return (
<Card padding="sm" data-testid={`guardrail-check-${info.name}`}>
<Card
variant="subtle"
padding="sm"
data-testid={`guardrail-check-${info.name}`}
>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<SectionHeader as="h4" size="xs" title={info.label} />
@@ -81,7 +81,13 @@ describe('RunDetailDrawer', () => {
root.render(<RunDetailDrawer run={run} onClose={vi.fn()} />),
);
const pre = content()!.querySelector('pre')!;
const card = pre.parentElement!;
// The scroll cap sits on an inner scroller inside the Card's padding,
// so the scrollbar stays clear of the rounded corners.
const scroller = pre.parentElement!;
expect(scroller.className.split(' ')).toEqual(
expect.arrayContaining(['scrollbar-overlay', 'overflow-y-auto']),
);
const card = scroller.parentElement!;
expect(card.getAttribute('data-slot')).toBe('card');
expect(card.getAttribute('data-variant')).toBe('filled');
expect(card.className.split(' ')).toContain('bg-muted');
@@ -29,11 +29,7 @@ export default function RunDetailDrawer({
if (!run) return null;
return (
<Sheet open onOpenChange={(open) => !open && onClose()}>
<SheetContent
side="right"
className="sm:max-w-xl"
aria-describedby={undefined}
>
<SheetContent side="right" size="detail" aria-describedby={undefined}>
{/* The sheet owns no padding of its own; the body sets it. */}
<div className="flex min-h-0 flex-1 flex-col gap-4 p-6">
<SheetTitle>{t('agents.schedules.runDetails.title')}</SheetTitle>
@@ -78,14 +74,12 @@ export default function RunDetailDrawer({
: t('agents.schedules.runDetails.error')
}
/>
<Card
variant="filled"
padding="sm"
className="max-h-48 overflow-y-auto"
>
<pre className="font-mono text-xs wrap-break-word whitespace-pre-wrap">
{run.error}
</pre>
<Card variant="filled" padding="sm">
<div className="scrollbar-overlay max-h-48 overflow-y-auto">
<pre className="font-mono text-xs wrap-break-word whitespace-pre-wrap">
{run.error}
</pre>
</div>
</Card>
</section>
)}
@@ -0,0 +1,69 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
const runs = [
{
id: 'r1',
schedule_id: 's1',
status: 'success',
scheduled_for: '2026-09-21T09:00:00Z',
started_at: '2026-09-21T09:00:00Z',
finished_at: '2026-09-21T09:00:42Z',
trigger_source: 'cron',
prompt_tokens: 4000,
generated_tokens: 812,
},
];
vi.mock('react-redux', () => ({
useSelector: (selector: (s: unknown) => unknown) =>
selector({
preference: { token: 't' },
schedules: { byAgent: {}, runsBySchedule: { s1: runs } },
}),
useDispatch: () => vi.fn(),
}));
import RunLog from './RunLog';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
describe('RunLog', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
it('opens a run from its whole row, with duration and grouped tokens', async () => {
const onSelect = vi.fn();
await act(async () => {
root.render(<RunLog scheduleId="s1" onSelect={onSelect} />);
});
const heads = Array.from(container.querySelectorAll('th')).map(
(th) => th.textContent,
);
expect(heads).toContain('agents.schedules.runLog.duration');
// The default table head, not the eyebrow.
expect(container.querySelector('th')!.className).not.toContain('uppercase');
const row = container.querySelector('tbody tr') as HTMLTableRowElement;
expect(row.textContent).toContain('42.0 s');
expect(row.textContent).toContain('4.8k');
expect(row.querySelector('svg.lucide-chevron-right')).not.toBeNull();
// No separate Details link: the row is the target.
expect(row.querySelector('button')).toBeNull();
await act(async () => row.click());
expect(onSelect).toHaveBeenCalledWith(runs[0]);
});
});
+51 -37
View File
@@ -1,8 +1,9 @@
import { ChevronRight } from 'lucide-react';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { Button } from '../../components/ui/button';
import { EmptyState } from '../../components/ui/empty-state';
import {
Table,
TableBody,
@@ -12,16 +13,16 @@ import {
TableRow,
} from '../../components/ui/table';
import { selectToken } from '../../preferences/preferenceSlice';
import {
formatDurationMs,
formatTokens,
} from '../../settings/traces/traceUtils';
import type { AppDispatch, RootState } from '../../store';
import { formatDateTime } from '../../utils/dateTimeUtils';
import type { ScheduleRun } from '../types/schedule';
import ScheduleStatusBadge from './StatusBadge';
import { loadRunsForSchedule, selectRunsForSchedule } from './schedulesSlice';
// The column heads are a label row, so they take the eyebrow recipe.
const HEADER_CELL =
'text-muted-foreground text-xs font-semibold tracking-wider uppercase';
export type RunLogProps = {
scheduleId: string;
onSelect?: (run: ScheduleRun) => void;
@@ -31,7 +32,18 @@ const formatTimestamp = (value?: string | null): string => {
return value ? formatDateTime(value) : '—';
};
/** Paginated run log for a schedule (SSE updates merge via schedulesSlice). */
/** How long a run took, or a dash while it hasn't started or finished. */
const runDuration = (run: ScheduleRun): string => {
if (!run.started_at || !run.finished_at) return '—';
return formatDurationMs(
Date.parse(run.finished_at) - Date.parse(run.started_at),
);
};
/**
* A schedule's run log (SSE updates merge via schedulesSlice). Each row opens
* the run's details when `onSelect` is given.
*/
export default function RunLog({ scheduleId, onSelect }: RunLogProps) {
const { t } = useTranslation();
const dispatch = useDispatch<AppDispatch>();
@@ -47,9 +59,11 @@ export default function RunLog({ scheduleId, onSelect }: RunLogProps) {
if (runs.length === 0) {
return (
<p className="text-muted-foreground py-3 text-sm">
{t('agents.schedules.runLog.empty')}
</p>
<EmptyState
size="xs"
illustration="none"
title={t('agents.schedules.runLog.empty')}
/>
);
}
@@ -57,24 +71,26 @@ export default function RunLog({ scheduleId, onSelect }: RunLogProps) {
<Table minWidth="min-w-0">
<TableHead>
<TableRow>
<TableHeader className={HEADER_CELL}>
{t('agents.schedules.runLog.when')}
</TableHeader>
<TableHeader className={HEADER_CELL}>
{t('agents.schedules.runDetails.status')}
</TableHeader>
<TableHeader className={HEADER_CELL}>
{t('agents.schedules.runDetails.tokens')}
</TableHeader>
<TableHeader className={HEADER_CELL}>
{t('agents.schedules.runDetails.trigger')}
</TableHeader>
<TableHeader className={HEADER_CELL}></TableHeader>
<TableHeader>{t('agents.schedules.runLog.when')}</TableHeader>
<TableHeader>{t('agents.schedules.runDetails.status')}</TableHeader>
<TableHeader>{t('agents.schedules.runLog.duration')}</TableHeader>
<TableHeader>{t('agents.schedules.runDetails.tokens')}</TableHeader>
<TableHeader>{t('agents.schedules.runDetails.trigger')}</TableHeader>
{onSelect && (
<TableHeader width="40px" align="center">
<span className="sr-only">
{t('agents.schedules.runLog.details')}
</span>
</TableHeader>
)}
</TableRow>
</TableHead>
<TableBody>
{runs.map((run) => (
<TableRow key={run.id}>
<TableRow
key={run.id}
onClick={onSelect ? () => onSelect(run) : undefined}
>
<TableCell>{formatTimestamp(run.scheduled_for)}</TableCell>
<TableCell>
<div className="flex items-center gap-1.5">
@@ -86,25 +102,23 @@ export default function RunLog({ scheduleId, onSelect }: RunLogProps) {
)}
</div>
</TableCell>
<TableCell>{run.prompt_tokens + run.generated_tokens}</TableCell>
<TableCell className="tabular-nums">{runDuration(run)}</TableCell>
<TableCell className="tabular-nums">
{formatTokens(run.prompt_tokens + run.generated_tokens)}
</TableCell>
<TableCell>
{t(`agents.schedules.trigger.${run.trigger_source}`, {
defaultValue: run.trigger_source,
})}
</TableCell>
<TableCell>
{onSelect && (
<Button
type="button"
variant="link"
size="xs"
onClick={() => onSelect(run)}
className="-mx-2 -my-1"
>
{t('agents.schedules.runLog.details')}
</Button>
)}
</TableCell>
{onSelect && (
<TableCell align="center">
<ChevronRight
aria-hidden
className="text-muted-foreground size-4"
/>
</TableCell>
)}
</TableRow>
))}
</TableBody>
@@ -0,0 +1,138 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) =>
opts && 'count' in opts ? `${key}:${opts.count}` : key,
}),
}));
vi.mock('./RunLog', () => ({ default: () => <div data-testid="runlog" /> }));
import ScheduleRow from './ScheduleRow';
import type { Schedule } from '../types/schedule';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const base: Schedule = {
id: 's1',
user_id: 'u',
agent_id: 'a1',
trigger_type: 'recurring',
name: 'Weekly compliance digest',
instruction: 'Summarise expiring certificates.',
status: 'active',
cron: '0 9 * * 1',
timezone: 'Europe/Berlin',
next_run_at: '2026-09-28T07:00:00Z',
last_run_at: '2026-09-21T07:00:00Z',
tool_allowlist: [],
created_via: 'ui',
consecutive_failure_count: 0,
created_at: '2026-09-01T00:00:00Z',
updated_at: '2026-09-01T00:00:00Z',
};
describe('ScheduleRow', () => {
let container: HTMLDivElement;
let root: Root;
const handlers = {
onToggleRuns: vi.fn(),
onEdit: vi.fn(),
onSetPaused: vi.fn(),
onRunNow: vi.fn(),
onDelete: vi.fn(),
onSelectRun: vi.fn(),
};
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
const render = async (schedule: Schedule, expanded = false) => {
await act(async () => {
root.render(
<ScheduleRow schedule={schedule} expanded={expanded} {...handlers} />,
);
});
};
const buttons = () =>
Array.from(container.querySelectorAll<HTMLButtonElement>('button'));
it('is a subtle panel with one visible action and a menu', async () => {
await render(base);
const card = container.querySelector<HTMLElement>('[data-slot="card"]')!;
expect(card.dataset.variant).toBe('subtle');
const runNow = buttons().find((b) =>
b.textContent?.includes('agents.schedules.runNow'),
)!;
expect(runNow.dataset.variant).toBe('outline');
expect(runNow.dataset.size).toBe('sm');
expect(runNow.dataset.shape).toBe('pill');
// Edit, Pause and Delete live in the menu, not as pills.
expect(
buttons().some((b) => b.textContent === 'agents.schedules.edit'),
).toBe(false);
expect(
container.querySelector('[aria-label="agents.schedules.actions"]'),
).not.toBeNull();
await act(async () => runNow.click());
expect(handlers.onRunNow).toHaveBeenCalledWith(base);
});
it('reads its meta with icons, and shows the instruction under a name', async () => {
await render(base);
expect(container.textContent).toContain('Summarise expiring certificates.');
expect(container.textContent).toContain('Europe/Berlin');
expect(container.textContent).toContain('agents.schedules.meta.nextRun');
expect(container.textContent).not.toContain('tz:');
expect(container.querySelector('svg.lucide-repeat')).not.toBeNull();
});
it('offers Resume while paused and flags a failure streak', async () => {
await render({ ...base, status: 'paused', consecutive_failure_count: 2 });
expect(
buttons().some((b) => b.textContent?.includes('agents.schedules.resume')),
).toBe(true);
const streak = Array.from(
container.querySelectorAll<HTMLElement>('[data-slot="badge"]'),
).find((b) => b.textContent?.includes('agents.schedules.failureStreak'))!;
expect(streak.dataset.variant).toBe('destructive');
expect(streak.textContent).toContain(':2');
});
it('toggles its runs with a chevron and shows them flush in the card', async () => {
await render(base, true);
const toggle = container.querySelector<HTMLButtonElement>(
'button[aria-label="agents.schedules.hideRuns"]',
)!;
expect(toggle.getAttribute('aria-expanded')).toBe('true');
expect(container.querySelector('[data-testid="runlog"]')).not.toBeNull();
await act(async () => toggle.click());
expect(handlers.onToggleRuns).toHaveBeenCalledWith('s1');
});
it('shows a one-time task with its run time and only a menu', async () => {
await render({
...base,
trigger_type: 'once',
cron: null,
run_at: '2026-10-01T08:00:00Z',
});
expect(container.textContent).toContain('agents.schedules.meta.runsAt');
expect(
buttons().some((b) => b.textContent?.includes('agents.schedules.runNow')),
).toBe(false);
expect(
container.querySelector('button[aria-label="agents.schedules.showRuns"]'),
).toBeNull();
});
});
@@ -0,0 +1,257 @@
import {
ChevronDown,
Clock,
History,
Pause,
Pencil,
Play,
Repeat,
Trash2,
TriangleAlert,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Badge } from '../../components/ui/badge';
import { Button } from '../../components/ui/button';
import { Card } from '../../components/ui/card';
import { ActionMenu, type MenuOption } from '../../components/ui/dropdown-menu';
import { IconButton } from '../../components/ui/icon-button';
import { cn } from '@/lib/utils';
import { formatDateTime, formatRelative } from '../../utils/dateTimeUtils';
import type { Schedule, ScheduleRun } from '../types/schedule';
import RunLog from './RunLog';
import ScheduleStatusBadge from './StatusBadge';
import { formatCron } from './cronBuilder';
// Timezones and dates carry slashes: React escapes on render, so i18next
// must not escape them first.
const NO_ESCAPE = { interpolation: { escapeValue: false } } as const;
const STATUS_DOT: Record<Schedule['status'], string> = {
active: 'bg-success',
paused: 'bg-warning',
completed: 'bg-muted-foreground',
cancelled: 'bg-muted-foreground',
};
export type ScheduleRowProps = {
schedule: Schedule;
/** Whether the run log is open under the row (recurring schedules). */
expanded: boolean;
onToggleRuns: (scheduleId: string) => void;
onEdit: (schedule: Schedule) => void;
onSetPaused: (schedule: Schedule, paused: boolean) => void;
onRunNow: (schedule: Schedule) => void;
onDelete: (schedule: Schedule) => void;
onSelectRun: (run: ScheduleRun) => void;
};
/**
* One schedule on an agent's Schedules page: a subtle panel with a status dot,
* the name and status, the instruction, one meta line, the common action as
* a button (Run now, or Resume while paused) and the rest in a menu. A
* recurring schedule opens its run log flush under the row.
*
* Args:
* schedule: The schedule to show.
* expanded: Whether its run log is open.
* onToggleRuns: Opens or closes the run log.
* onEdit: Opens the schedule form.
* onSetPaused: Pauses (true) or resumes (false) a recurring schedule.
* onRunNow: Runs the schedule once now.
* onDelete: Asks to delete the schedule (or cancel a one-time task).
* onSelectRun: Opens a run's details.
*/
export default function ScheduleRow({
schedule,
expanded,
onToggleRuns,
onEdit,
onSetPaused,
onRunNow,
onDelete,
onSelectRun,
}: ScheduleRowProps) {
const { t } = useTranslation();
const recurring = schedule.trigger_type === 'recurring';
const active = schedule.status === 'active';
const paused = schedule.status === 'paused';
const title = schedule.name || schedule.instruction.slice(0, 80);
const edit: MenuOption = {
label: t('agents.schedules.edit'),
icon: Pencil,
onClick: () => onEdit(schedule),
};
const remove: MenuOption = {
label: t('agents.schedules.delete'),
icon: Trash2,
variant: 'destructive',
onClick: () => onDelete(schedule),
};
const menu: MenuOption[] = recurring
? active
? [
edit,
{
label: t('agents.schedules.pause'),
icon: Pause,
onClick: () => onSetPaused(schedule, true),
},
remove,
]
: paused
? [
edit,
{
label: t('agents.schedules.runNow'),
icon: Play,
onClick: () => onRunNow(schedule),
},
remove,
]
: [remove]
: active
? [
edit,
{
label: t('agents.schedules.cancelTask'),
icon: Trash2,
variant: 'destructive',
onClick: () => onDelete(schedule),
},
]
: [remove];
const meta: { icon: typeof Clock; text: string }[] = [];
if (recurring) {
meta.push({
icon: Repeat,
text: `${formatCron(schedule.cron, t)}, ${schedule.timezone}`,
});
if (active && schedule.next_run_at) {
meta.push({
icon: Clock,
text: t('agents.schedules.meta.nextRun', {
...NO_ESCAPE,
// A past next run (the scheduler is behind) reads as a date, not
// "… ago".
time:
Date.parse(schedule.next_run_at) > Date.now()
? formatRelative(schedule.next_run_at, { future: true })
: formatDateTime(schedule.next_run_at),
}),
});
}
} else if (schedule.run_at) {
meta.push({
icon: Clock,
text: t('agents.schedules.meta.runsAt', {
...NO_ESCAPE,
time: `${formatDateTime(schedule.run_at)}, ${schedule.timezone}`,
}),
});
}
if (schedule.last_run_at) {
meta.push({
icon: History,
text: t('agents.schedules.meta.lastRun', {
...NO_ESCAPE,
time: formatRelative(schedule.last_run_at),
}),
});
}
return (
<Card variant="subtle" padding="none" data-schedule-id={schedule.id}>
<div className="flex flex-col gap-3 p-4 sm:flex-row sm:items-start">
<div className="flex min-w-0 flex-1 items-start gap-3">
<span
aria-hidden
className={cn(
'mt-1.5 size-2 shrink-0 rounded-full',
STATUS_DOT[schedule.status],
)}
/>
<div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium wrap-break-word">{title}</span>
<ScheduleStatusBadge status={schedule.status} />
{schedule.consecutive_failure_count > 0 && (
<Badge variant="destructive">
<TriangleAlert aria-hidden />
{t('agents.schedules.failureStreak', {
count: schedule.consecutive_failure_count,
})}
</Badge>
)}
</div>
{schedule.name && (
<p className="text-muted-foreground line-clamp-1 text-sm">
{schedule.instruction}
</p>
)}
{meta.length > 0 && (
<p className="text-muted-foreground flex flex-wrap items-center gap-x-3 gap-y-1 text-xs">
{meta.map(({ icon: Icon, text }) => (
<span key={text} className="inline-flex items-center gap-1">
<Icon aria-hidden className="size-3.5 shrink-0" />
{text}
</span>
))}
</p>
)}
</div>
</div>
<div className="flex shrink-0 items-center gap-1 pl-5 sm:pl-0">
{recurring && (active || paused) && (
<Button
type="button"
variant="outline"
size="sm"
shape="pill"
onClick={() =>
active ? onRunNow(schedule) : onSetPaused(schedule, false)
}
>
<Play />
{active
? t('agents.schedules.runNow')
: t('agents.schedules.resume')}
</Button>
)}
<ActionMenu
triggerLabel={t('agents.schedules.actions')}
options={menu}
/>
{recurring && (
<IconButton
label={
expanded
? t('agents.schedules.hideRuns')
: t('agents.schedules.showRuns')
}
variant="ghost-muted"
size="icon-xs"
aria-expanded={expanded}
onClick={() => onToggleRuns(schedule.id)}
>
<ChevronDown
aria-hidden
className={cn(
'transition-transform duration-200',
expanded && 'rotate-180',
)}
/>
</IconButton>
)}
</div>
</div>
{recurring && expanded && (
<div className="border-border border-t">
<RunLog scheduleId={schedule.id} onSelect={onSelectRun} />
</div>
)}
</Card>
);
}
@@ -0,0 +1,170 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) =>
opts && 'count' in opts ? `${key}:${opts.count}` : key,
}),
}));
const schedules = [
{
id: 's1',
agent_id: 'a1',
trigger_type: 'recurring',
name: 'Weekly digest',
instruction: 'Summarise.',
status: 'active',
cron: '0 9 * * 1',
timezone: 'Europe/Berlin',
next_run_at: '2099-09-28T07:00:00Z',
consecutive_failure_count: 0,
},
{
id: 's2',
agent_id: 'a1',
trigger_type: 'recurring',
name: 'Daily queue',
instruction: 'Check.',
status: 'paused',
cron: '30 8 * * 1-5',
timezone: 'Europe/Berlin',
consecutive_failure_count: 2,
},
];
vi.mock('react-redux', () => ({
useSelector: (selector: (s: unknown) => unknown) =>
selector({
preference: { token: 't' },
schedules: { byAgent: { a1: schedules }, runsBySchedule: {} },
}),
useDispatch: () => vi.fn(() => ({ unwrap: () => Promise.resolve() })),
}));
vi.mock('../../api/services/userService', () => ({
default: {
getAgent: () =>
Promise.resolve({
ok: true,
json: () =>
Promise.resolve({ id: 'a1', name: 'Carrier FAQ', tools: [] }),
}),
},
}));
vi.mock('../../api/services/schedulesService', () => ({
default: {
statsForAgent: () =>
Promise.resolve({
days: 30,
runs: 14,
failed: 3,
tokens: 48230,
latest_failure: {
scheduled_for: '2026-09-14T09:00:00Z',
status: 'timeout',
error_type: 'timeout',
},
}),
},
}));
vi.mock('./ScheduleRow', () => ({
default: ({ schedule }: { schedule: { id: string } }) => (
<div data-testid={`row-${schedule.id}`} />
),
}));
vi.mock('./ScheduleFormModal', () => ({ default: () => null }));
vi.mock('./RunDetailDrawer', () => ({ default: () => null }));
vi.mock('../../modals/ConfirmationModal', () => ({ default: () => null }));
vi.mock('../../navigation/SectionPageHeader', () => ({
CurrentSectionHeader: () => <h1>Schedules</h1>,
}));
vi.mock('../../navigation/SectionPills', () => ({ default: () => null }));
import SchedulesView from './SchedulesView';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
describe('SchedulesView', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(async () => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
await act(async () => {
root.render(
<MemoryRouter initialEntries={['/agents/manage/schedules/a1']}>
<Routes>
<Route
path="/agents/manage/schedules/:agentId"
element={<SchedulesView />}
/>
</Routes>
</MemoryRouter>,
);
});
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
it('puts New schedule in the agent toolbar, with no second heading', async () => {
const toolbar = container.querySelector('[data-slot="page-toolbar"]')!;
expect(toolbar.textContent).toContain('Carrier FAQ');
const create = Array.from(toolbar.querySelectorAll('button')).find((b) =>
b.textContent?.includes('agents.schedules.newRecurring'),
)!;
expect(create.dataset.variant).toBe('default');
expect(create.dataset.size).toBe('field');
expect(create.dataset.shape).toBe('pill');
expect(container.querySelectorAll('h1, h2')).toHaveLength(1);
});
it('shows the stat row as StatCards, failures in the destructive tone', async () => {
const tiles = Array.from(
container.querySelectorAll<HTMLElement>('[data-slot="card"]'),
);
expect(tiles).toHaveLength(4);
expect(container.textContent).toContain('agents.schedules.stats.active');
expect(container.textContent).toContain('14');
const failed = tiles.find((c) =>
c.textContent?.includes('agents.schedules.stats.failed'),
)!;
expect(failed.dataset.tone).toBe('destructive');
expect(failed.textContent).toContain('3');
});
it('switches Recurring and One-time with underline tabs', async () => {
const tabs = Array.from(container.querySelectorAll('[role="tab"]'));
expect(tabs.map((t) => t.textContent)).toEqual([
'agents.schedules.recurring2',
'agents.schedules.oneTime0',
]);
expect(tabs[0].getAttribute('data-variant')).toBe('underline');
expect(container.querySelector('[data-testid="row-s1"]')).not.toBeNull();
expect(container.querySelector('[data-testid="row-s2"]')).not.toBeNull();
});
it('shows an empty tab as an EmptyState with New schedule', async () => {
const oneTime = Array.from(
container.querySelectorAll<HTMLElement>('[role="tab"]'),
)[1];
await act(async () => {
oneTime.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
oneTime.dispatchEvent(
new PointerEvent('pointerdown', { bubbles: true, button: 0 }),
);
oneTime.click();
});
const empty = container.querySelector('[data-slot="empty-state"]')!;
expect(empty.textContent).toContain('agents.schedules.noOneTime');
expect(empty.querySelector('button')?.textContent).toContain(
'agents.schedules.newRecurring',
);
});
});
+193 -213
View File
@@ -4,26 +4,38 @@ import { useDispatch, useSelector } from 'react-redux';
import { useParams } from 'react-router-dom';
import userService from '../../api/services/userService';
import { Plus } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { EmptyState } from '@/components/ui/empty-state';
import { LoadingState } from '@/components/ui/loading-state';
import { SectionHeader } from '@/components/ui/section-header';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import schedulesService from '../../api/services/schedulesService';
import StatCard from '../../components/StatCard';
import { Button } from '../../components/ui/button';
import { Card } from '../../components/ui/card';
import ConfirmationModal from '../../modals/ConfirmationModal';
import { ActiveState } from '../../models/misc';
import { selectToken } from '../../preferences/preferenceSlice';
import type { AppDispatch, RootState } from '../../store';
import { formatDateTime } from '../../utils/dateTimeUtils';
import { formatTokens } from '../../settings/traces/traceUtils';
import {
formatDateOnly,
formatDateTime,
formatRelative,
} from '../../utils/dateTimeUtils';
import AgentPageToolbar, { LastUsedMeta } from '../components/AgentPageToolbar';
import SectionShell from '../../navigation/SectionShell';
import type { Agent } from '../types';
import type {
Schedule,
ScheduleCreatePayload,
ScheduleRun,
ScheduleStats,
} from '../types/schedule';
import RunDetailDrawer from './RunDetailDrawer';
import RunLog from './RunLog';
import ScheduleFormModal from './ScheduleFormModal';
import ScheduleStatusBadge from './StatusBadge';
import { formatCron } from './cronBuilder';
import ScheduleRow from './ScheduleRow';
import {
createSchedule,
deleteSchedule,
@@ -38,9 +50,8 @@ import {
// must not escape them first.
const NO_ESCAPE = { interpolation: { escapeValue: false } } as const;
const formatTimestamp = (value?: string | null): string => {
return value ? formatDateTime(value) : '—';
};
// The stat row's window, in days.
const STATS_DAYS = 30;
/** Standalone Schedules page for an agent: list, create, edit, pause, run, delete. */
export default function SchedulesView() {
@@ -65,6 +76,28 @@ export default function SchedulesView() {
const schedules = useSelector((state: RootState) =>
selectSchedulesForAgent(state, agentId ?? ''),
);
// undefined while loading, null when the stats request failed.
const [stats, setStats] = useState<ScheduleStats | null | undefined>(
undefined,
);
// Refresh the totals whenever the list changes (a run finished, a
// schedule was paused or removed): the SSE feed updates `schedules`.
useEffect(() => {
if (!agentId) return;
let cancelled = false;
schedulesService
.statsForAgent(agentId, token, STATS_DAYS)
.then((next) => {
if (!cancelled) setStats(next);
})
.catch(() => {
if (!cancelled) setStats(null);
});
return () => {
cancelled = true;
};
}, [agentId, token, schedules]);
useEffect(() => {
if (!agentId) return;
@@ -151,218 +184,165 @@ export default function SchedulesView() {
}
};
const activeCount = schedules.filter((s) => s.status === 'active').length;
const pausedCount = schedules.filter((s) => s.status === 'paused').length;
const nextRunAt = schedules
.filter((s) => s.status === 'active' && s.next_run_at)
.map((s) => s.next_run_at as string)
.sort()[0];
// A next run in the past means the scheduler hasn't picked it up yet.
const nextRunOverdue =
Boolean(nextRunAt) && Date.parse(nextRunAt as string) < Date.now();
const statsLoading = stats === undefined;
const failedCount = stats?.failed ?? 0;
const latestFailure = stats?.latest_failure;
const newScheduleButton = (
<Button type="button" size="field" shape="pill" onClick={openCreate}>
<Plus />
{t('agents.schedules.newRecurring')}
</Button>
);
const renderList = (list: Schedule[], emptyKey: string) =>
list.length === 0 ? (
<Card variant="subtle" padding="lg">
<EmptyState
size="sm"
illustration="none"
title={t(emptyKey)}
action={
<Button
type="button"
variant="outline"
size="sm"
shape="pill"
onClick={openCreate}
>
<Plus />
{t('agents.schedules.newRecurring')}
</Button>
}
/>
</Card>
) : (
<ul className="flex flex-col gap-3">
{list.map((schedule) => (
<li key={schedule.id}>
<ScheduleRow
schedule={schedule}
expanded={expanded === schedule.id}
onToggleRuns={(id) => setExpanded(expanded === id ? null : id)}
onEdit={openEdit}
onSetPaused={(target, paused) =>
dispatch(
setSchedulePaused({
id: target.id,
action: paused ? 'pause' : 'resume',
token,
}),
)
}
onRunNow={(target) =>
dispatch(runScheduleNow({ id: target.id, token }))
}
onDelete={requestDelete}
onSelectRun={(run) => setActiveRun(run)}
/>
</li>
))}
</ul>
);
return (
<SectionShell pills>
<div className="flex flex-col gap-3">
{agent && (
<div className="flex flex-col gap-1">
<p className="text-foreground">{agent.name}</p>
<p className="text-muted-foreground text-xs">
{agent.last_used_at
? t('agents.logs.lastUsedAt') +
' ' +
formatDateTime(agent.last_used_at)
: t('agents.logs.noUsageHistory')}
</p>
</div>
)}
</div>
<SectionShell>
{agent && (
<AgentPageToolbar
name={agent.name}
meta={<LastUsedMeta lastUsedAt={agent.last_used_at} />}
actions={newScheduleButton}
/>
)}
{loadingAgent ? (
<LoadingState fill="block" />
) : (
agent && (
<div className="flex flex-col gap-4 p-4">
<SectionHeader
title={t('agents.schedules.heading')}
actions={
<Button type="button" size="sm" onClick={openCreate}>
{t('agents.schedules.newRecurring')}
</Button>
}
/>
<section className="flex flex-col gap-2">
<SectionHeader
as="h3"
size="sm"
title={`${t('agents.schedules.recurring')} (${recurring.length})`}
<div className="flex flex-col gap-8">
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<StatCard
label={t('agents.schedules.stats.active')}
value={activeCount}
sub={
pausedCount > 0
? t('agents.schedules.stats.paused', {
count: pausedCount,
})
: undefined
}
/>
{recurring.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t('agents.schedules.noRecurring')}
</p>
) : (
<ul className="flex flex-col gap-3">
{recurring.map((schedule) => (
<li
key={schedule.id}
className="border-border bg-card rounded-lg border p-3"
>
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2">
<p className="font-semibold">
{schedule.name ||
schedule.instruction.slice(0, 80)}
</p>
<ScheduleStatusBadge status={schedule.status} />
</div>
<p className="text-muted-foreground text-xs">
{formatCron(schedule.cron, t)} ·{' '}
{t('agents.schedules.timezoneMeta', {
...NO_ESCAPE,
timezone: schedule.timezone,
})}{' '}
·{' '}
{t('agents.schedules.nextRunMeta', {
...NO_ESCAPE,
time: formatTimestamp(schedule.next_run_at),
})}
</p>
</div>
<div className="flex gap-2">
<Button
type="button"
variant="outline-primary"
size="sm"
shape="pill"
onClick={() => openEdit(schedule)}
>
{t('agents.schedules.edit')}
</Button>
<Button
type="button"
variant="outline-primary"
size="sm"
shape="pill"
onClick={() =>
dispatch(
setSchedulePaused({
id: schedule.id,
action:
schedule.status === 'active'
? 'pause'
: 'resume',
token,
}),
)
}
>
{schedule.status === 'active'
? t('agents.schedules.pause')
: t('agents.schedules.resume')}
</Button>
<Button
type="button"
variant="outline-primary"
size="sm"
shape="pill"
onClick={() =>
dispatch(
runScheduleNow({ id: schedule.id, token }),
)
}
>
{t('agents.schedules.runNow')}
</Button>
<Button
type="button"
variant="destructive-outline"
size="sm"
shape="pill"
onClick={() => requestDelete(schedule)}
>
{t('agents.schedules.delete')}
</Button>
</div>
</div>
<Button
type="button"
variant="link"
size="xs"
onClick={() =>
setExpanded(
expanded === schedule.id ? null : schedule.id,
)
}
className="mt-0.5 -ml-2"
>
{expanded === schedule.id
? t('agents.schedules.hideRuns')
: t('agents.schedules.showRuns')}
</Button>
{expanded === schedule.id && (
<div className="mt-2">
<RunLog
scheduleId={schedule.id}
onSelect={(run) => setActiveRun(run)}
/>
</div>
)}
</li>
))}
</ul>
)}
</section>
<section className="flex flex-col gap-2">
<SectionHeader
as="h3"
size="sm"
title={`${t('agents.schedules.oneTime')} (${oneTime.length})`}
<StatCard
label={t('agents.schedules.stats.nextRun')}
value={
!nextRunAt
? '—'
: nextRunOverdue
? t('agents.schedules.stats.overdue')
: formatRelative(nextRunAt, { future: true })
}
valueTone={nextRunOverdue ? 'warning' : undefined}
sub={nextRunAt ? formatDateTime(nextRunAt) : undefined}
/>
{oneTime.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t('agents.schedules.noOneTime')}
</p>
) : (
<ul className="flex flex-col gap-2">
{oneTime.map((schedule) => (
<li
key={schedule.id}
className="border-border bg-card rounded-lg border p-3 text-sm"
>
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2">
<p className="font-semibold">
{schedule.name ||
schedule.instruction.slice(0, 80)}
</p>
<ScheduleStatusBadge status={schedule.status} />
</div>
<p className="text-muted-foreground text-xs">
runs at {formatTimestamp(schedule.run_at)}
</p>
</div>
<div className="flex gap-2">
{schedule.status === 'active' && (
<Button
type="button"
variant="outline-primary"
size="sm"
shape="pill"
onClick={() => openEdit(schedule)}
>
{t('agents.schedules.edit')}
</Button>
)}
{schedule.status === 'active' && (
<Button
type="button"
variant="destructive-outline"
size="sm"
shape="pill"
onClick={() => requestDelete(schedule)}
>
{t('agents.schedules.cancel')}
</Button>
)}
</div>
</div>
</li>
))}
</ul>
)}
</section>
<StatCard
label={t('agents.schedules.stats.runs', { days: STATS_DAYS })}
value={stats ? stats.runs : '—'}
loading={statsLoading}
sub={
stats
? t('agents.schedules.stats.tokens', {
tokens: formatTokens(stats.tokens),
})
: undefined
}
/>
<StatCard
label={t('agents.schedules.stats.failed', {
days: STATS_DAYS,
})}
value={stats ? failedCount : '—'}
loading={statsLoading}
tone={failedCount > 0 ? 'destructive' : 'default'}
valueTone={failedCount > 0 ? 'destructive' : undefined}
sub={
latestFailure
? t('agents.schedules.stats.latestFailure', {
...NO_ESCAPE,
status: t(
`agents.schedules.status.${latestFailure.status}`,
),
date: formatDateOnly(latestFailure.scheduled_for),
})
: undefined
}
/>
</div>
<Tabs defaultValue="recurring">
<TabsList variant="underline">
<TabsTrigger value="recurring" variant="underline">
{t('agents.schedules.recurring')}
<Badge variant="neutral">{recurring.length}</Badge>
</TabsTrigger>
<TabsTrigger value="once" variant="underline">
{t('agents.schedules.oneTime')}
<Badge variant="neutral">{oneTime.length}</Badge>
</TabsTrigger>
</TabsList>
<TabsContent value="recurring" className="mt-4">
{renderList(recurring, 'agents.schedules.noRecurring')}
</TabsContent>
<TabsContent value="once" className="mt-4">
{renderList(oneTime, 'agents.schedules.noOneTime')}
</TabsContent>
</Tabs>
<RunDetailDrawer
run={activeRun}
onClose={() => setActiveRun(null)}
@@ -268,3 +268,13 @@ describe('schedulesSlice SSE event handling', () => {
expect(stub.trigger_source).toBe('cron');
});
});
describe('selectSchedulesForAgent', () => {
it('returns a stable empty list for an agent that has not loaded', () => {
// A fresh [] per call re-runs every effect that depends on the list.
const state = { schedules: reducer(undefined, { type: '@@init' }) };
expect(selectSchedulesForAgent(state, 'missing')).toBe(
selectSchedulesForAgent(state, 'missing'),
);
});
});
@@ -333,12 +333,17 @@ const schedulesSlice = createSlice({
export const { applyEvent, resetSchedules } = schedulesSlice.actions;
export default schedulesSlice.reducer;
// Shared fallbacks: a fresh [] per call would change identity on every render
// and re-run any effect that depends on the selected list.
const NO_SCHEDULES: Schedule[] = [];
const NO_RUNS: ScheduleRun[] = [];
export const selectSchedulesForAgent = (
state: { schedules: SchedulesState },
agentId: string,
): Schedule[] => state.schedules.byAgent[agentId] ?? [];
): Schedule[] => state.schedules.byAgent[agentId] ?? NO_SCHEDULES;
export const selectRunsForSchedule = (
state: { schedules: SchedulesState },
scheduleId: string,
): ScheduleRun[] => state.schedules.runsBySchedule[scheduleId] ?? [];
): ScheduleRun[] => state.schedules.runsBySchedule[scheduleId] ?? NO_RUNS;
+13
View File
@@ -68,6 +68,19 @@ export type ScheduleRun = {
};
export type ScheduleListResponse = { schedules: Schedule[] };
/** Run totals for an agent's schedules over the last `days` days. */
export type ScheduleStats = {
days: number;
runs: number;
failed: number;
tokens: number;
latest_failure: {
scheduled_for: string;
status: ScheduleRunStatus;
error_type?: ScheduleRunErrorType | null;
} | null;
};
export type ScheduleResponse = { schedule: Schedule };
export type ScheduleRunListResponse = {
runs: ScheduleRun[];
@@ -0,0 +1,89 @@
import { Maximize, Minus, Plus, Redo2, Undo2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Panel, useReactFlow, useStore } from 'reactflow';
import { IconButton } from '@/components/ui/icon-button';
import { Separator } from '@/components/ui/separator';
interface CanvasControlsProps {
onUndo: () => void;
onRedo: () => void;
canUndo: boolean;
canRedo: boolean;
}
const ZOOM_DURATION_MS = 200;
/**
* The canvas's one control strip, bottom left: undo and redo, then zoom out,
* the zoom level, zoom in and fit view. Replaces React Flow's own
* `<Controls />`, whose buttons ignore the theme. Render inside `<ReactFlow>`.
*/
export default function CanvasControls({
onUndo,
onRedo,
canUndo,
canRedo,
}: CanvasControlsProps) {
const { t } = useTranslation();
const { zoomIn, zoomOut, fitView } = useReactFlow();
const zoom = useStore((state) => state.transform[2]);
return (
<Panel
position="bottom-left"
className="bg-card border-border flex items-center gap-0.5 rounded-full border p-1"
>
<IconButton
variant="ghost-muted"
size="icon-sm"
shape="pill"
onClick={onUndo}
disabled={!canUndo}
label={t('agents.workflow.undo')}
hint={t('agents.workflow.undoHint')}
icon={Undo2}
/>
<IconButton
variant="ghost-muted"
size="icon-sm"
shape="pill"
onClick={onRedo}
disabled={!canRedo}
label={t('agents.workflow.redo')}
hint={t('agents.workflow.redoHint')}
icon={Redo2}
/>
<Separator orientation="vertical" className="mx-1 h-5" />
<IconButton
variant="ghost-muted"
size="icon-sm"
shape="pill"
onClick={() => zoomOut({ duration: ZOOM_DURATION_MS })}
label={t('agents.workflow.zoomOut')}
icon={Minus}
/>
<span className="text-foreground w-11 text-center text-xs tabular-nums">
{Math.round(zoom * 100)}%
</span>
<IconButton
variant="ghost-muted"
size="icon-sm"
shape="pill"
onClick={() => zoomIn({ duration: ZOOM_DURATION_MS })}
label={t('agents.workflow.zoomIn')}
icon={Plus}
/>
<IconButton
variant="ghost-muted"
size="icon-sm"
shape="pill"
onClick={() =>
fitView({ padding: 0.2, maxZoom: 0.8, duration: ZOOM_DURATION_MS })
}
label={t('agents.workflow.fitView')}
icon={Maximize}
/>
</Panel>
);
}
@@ -0,0 +1,83 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
import NodePalette from './NodePalette';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
describe('NodePalette', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
const render = (onAdd = vi.fn(), onDragStart = vi.fn()) => {
act(() => {
root.render(<NodePalette onAdd={onAdd} onDragStart={onDragStart} />);
});
return { onAdd, onDragStart };
};
const pill = (labelKey: string) =>
Array.from(container.querySelectorAll('button')).find((b) =>
b.textContent?.includes(labelKey),
) as HTMLButtonElement;
it('renders each node type as a draggable button', () => {
render();
const buttons = container.querySelectorAll('button');
expect(buttons).toHaveLength(6);
buttons.forEach((button) => {
expect(button.getAttribute('type')).toBe('button');
expect(button.getAttribute('draggable')).toBe('true');
});
});
it('adds a node of the pill type on click', () => {
const { onAdd } = render();
act(() => pill('agents.workflow.nodes.condition').click());
expect(onAdd).toHaveBeenCalledWith('condition');
act(() => pill('agents.workflow.builder.aiAgent').click());
expect(onAdd).toHaveBeenLastCalledWith('agent');
});
it('starts a drag with the pill type', () => {
const { onDragStart } = render();
const button = pill('agents.workflow.nodes.code');
act(() => {
button.dispatchEvent(new Event('dragstart', { bubbles: true }));
});
expect(onDragStart).toHaveBeenCalledWith(expect.anything(), 'code');
});
it('shows the shared tone on the icon square, with no hover swap', () => {
render();
const square = pill('agents.workflow.nodes.end').querySelector(
'span',
) as HTMLElement;
expect(square.className).toContain('bg-success/10');
// Radius by role: a tinted icon square at size-8 is rounded-md.
expect(square.className).toContain('rounded-md');
expect(container.innerHTML).not.toContain('group-hover:');
});
it('explains click-to-add under the groups', () => {
render();
expect(container.textContent).toContain(
'agents.workflow.builder.paletteHint',
);
});
});
@@ -0,0 +1,123 @@
import { type DragEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { SectionHeader } from '@/components/ui/section-header';
import { cn, focusRing } from '@/lib/utils';
import { NODE_META, nodeToneClass, type WorkflowNodeType } from './nodeTones';
interface PaletteEntry {
type: WorkflowNodeType;
group: 'core' | 'logic';
hintKey?: string;
}
const PALETTE: PaletteEntry[] = [
{ type: 'agent', group: 'core' },
{ type: 'end', group: 'core' },
{ type: 'note', group: 'core' },
{
type: 'state',
group: 'logic',
hintKey: 'agents.workflow.builder.setStateHint',
},
{
type: 'condition',
group: 'logic',
hintKey: 'agents.workflow.builder.conditionHint',
},
{ type: 'code', group: 'logic', hintKey: 'agents.workflow.builder.codeHint' },
];
const GROUPS: { group: PaletteEntry['group']; titleKey: string }[] = [
{ group: 'core', titleKey: 'agents.workflow.builder.coreNodes' },
{ group: 'logic', titleKey: 'agents.workflow.builder.logicNodes' },
];
interface NodePaletteItemProps {
entry: PaletteEntry;
onAdd: (nodeType: WorkflowNodeType) => void;
onDragStart: (e: DragEvent, nodeType: string) => void;
}
/**
* A palette pill: drag it onto the canvas, or click (or press Enter) to add
* the node without dragging.
*
* Args:
* entry: The palette entry to render.
* onAdd: Adds a node of the entry's type beside the selection.
* onDragStart: Starts dragging a node of the entry's type onto the canvas.
*/
function NodePaletteItem({ entry, onAdd, onDragStart }: NodePaletteItemProps) {
const { t } = useTranslation();
const { icon: Icon, labelKey } = NODE_META[entry.type];
const label = (
<span className="text-foreground text-sm font-medium">{t(labelKey)}</span>
);
return (
<button
type="button"
draggable
onDragStart={(e) => onDragStart(e, entry.type)}
onClick={() => onAdd(entry.type)}
className={cn(
'border-border bg-card hover:bg-accent flex cursor-grab items-center gap-3 rounded-full border px-4 py-2.5 text-left transition-colors outline-none',
focusRing,
)}
>
<span
className={cn(
'flex size-8 shrink-0 items-center justify-center rounded-md',
nodeToneClass(entry.type),
)}
>
<Icon className="size-4.5" aria-hidden="true" />
</span>
{entry.hintKey ? (
<span className="flex flex-col">
{label}
<span className="text-muted-foreground text-xs">
{t(entry.hintKey)}
</span>
</span>
) : (
label
)}
</button>
);
}
interface NodePaletteProps {
/** Adds a node of the given type without dragging (click or Enter). */
onAdd: (nodeType: WorkflowNodeType) => void;
/** Starts dragging a node of the given type onto the canvas. */
onDragStart: (e: DragEvent, nodeType: string) => void;
}
/** The builder's left rail of node types, grouped Core and Logic. */
export default function NodePalette({ onAdd, onDragStart }: NodePaletteProps) {
const { t } = useTranslation();
return (
<div className="border-border bg-background flex w-64 shrink-0 flex-col gap-6 overflow-y-auto border-r p-4">
{GROUPS.map(({ group, titleKey }) => (
<div key={group} className="flex flex-col gap-3">
<SectionHeader as="h3" size="sm" title={t(titleKey)} />
<div className="flex flex-col gap-2">
{PALETTE.filter((entry) => entry.group === group).map((entry) => (
<NodePaletteItem
key={entry.type}
entry={entry}
onAdd={onAdd}
onDragStart={onDragStart}
/>
))}
</div>
</div>
))}
<p className="text-muted-foreground text-xs">
{t('agents.workflow.builder.paletteHint')}
</p>
</div>
);
}
File diff suppressed because it is too large. Load diff
@@ -103,8 +103,8 @@ describe('WorkflowMiniMap', () => {
);
expect(tokens(r.busy)).toEqual(
expect.arrayContaining([
'hover:bg-primary/10',
'dark:hover:bg-primary/10',
'hover:bg-secondary',
'dark:hover:bg-secondary',
'animate-pulse',
]),
);
@@ -125,8 +125,8 @@ describe('WorkflowMiniMap', () => {
expect.arrayContaining([
'ring-2',
'ring-primary',
'hover:bg-primary/10',
'dark:hover:bg-primary/10',
'hover:bg-secondary',
'dark:hover:bg-secondary',
]),
);
expect(done).not.toContain('hover:bg-accent');
@@ -0,0 +1,25 @@
import { createContext, useContext } from 'react';
/**
* Model display names by model id, provided by the builder so the canvas
* nodes (rendered by React Flow, out of reach of props) can name a model.
*/
export const WorkflowModelsContext = createContext<Record<string, string>>({});
/**
* A model's display name.
*
* Args:
* modelId: The model id stored on the node.
*
* Returns:
* The display name, the id itself when the model is unknown, or undefined
* when no id is set.
*/
export function useModelDisplayName(
modelId: string | undefined,
): string | undefined {
const names = useContext(WorkflowModelsContext);
if (!modelId) return undefined;
return names[modelId] || modelId;
}
@@ -0,0 +1,134 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock('react-redux', () => ({
useSelector: () => null,
useDispatch: () => vi.fn(),
}));
vi.mock('./WorkflowRunArtifacts', () => ({ default: () => null }));
import { ExecutionDetails, RunArtifactsSection } from './WorkflowPreview';
import type { WorkflowNode } from '../types/workflow';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const nodes = [
{
id: 'n1',
type: 'agent',
title: 'Summarise',
position: { x: 0, y: 0 },
data: {},
},
] as unknown as WorkflowNode[];
const steps = [
{
nodeId: 'n1',
nodeType: 'agent',
nodeTitle: 'Summarise',
status: 'completed',
output: 'Three carriers expire this month.',
stateDelta: { region: 'EU', query: 'ignored' },
},
] as never;
describe('WorkflowPreview execution sections', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
const renderDetails = async (isOpen = true) => {
await act(async () => {
root.render(
<ExecutionDetails
steps={steps}
nodes={nodes}
isOpen={isOpen}
onToggle={() => undefined}
/>,
);
});
};
// The answer's step-row recipe (AnswerFlow): ghost sm at ml-3.5, a muted
// icon, muted text, and a chevron that turns while open.
it('draws the Execution details toggle as an answer step row', async () => {
await renderDetails();
const toggle = container.querySelector<HTMLButtonElement>('button')!;
expect(toggle.dataset.variant).toBe('ghost');
expect(toggle.dataset.size).toBe('sm');
expect(toggle.getAttribute('aria-expanded')).toBe('true');
expect(toggle.className).toContain('ml-3.5');
const icons = toggle.querySelectorAll('svg');
expect(icons[0].getAttribute('class')).toContain('text-muted-foreground');
expect(icons[icons.length - 1].getAttribute('class')).toContain(
'rotate-180',
);
expect(toggle.querySelector('p')).toBeNull();
});
it('draws each step as a subtle panel with its output in a filled well', async () => {
await renderDetails();
const step = container.querySelector<HTMLElement>(
'[data-slot="card"][data-variant="subtle"]',
)!;
expect(step.dataset.padding).toBe('sm');
const well = step.querySelector<HTMLElement>(
'[data-slot="card"][data-variant="filled"]',
)!;
expect(well.dataset.padding).toBe('sm');
expect(well.textContent).toContain('Three carriers expire this month.');
// No hand-rolled muted boxes: a muted box in a muted box disappears.
expect(
container.querySelector(
'div.bg-muted:not([data-slot="card"]), span.bg-muted',
),
).toBeNull();
});
it('shows state changes as mono neutral badges, without query', async () => {
await renderDetails();
const badges = Array.from(
container.querySelectorAll<HTMLElement>('[data-slot="badge"]'),
);
expect(badges).toHaveLength(1);
expect(badges[0].dataset.variant).toBe('neutral');
expect(badges[0].className).toContain('font-mono');
expect(badges[0].textContent).toContain('region');
expect(badges[0].textContent).toContain('EU');
});
it('draws the Artifacts toggle as the same step row', async () => {
await act(async () => {
root.render(
<RunArtifactsSection
workflowRunId="run-1"
isOpen={false}
onToggle={() => undefined}
/>,
);
});
const toggle = container.querySelector<HTMLButtonElement>('button')!;
expect(toggle.dataset.variant).toBe('ghost');
expect(toggle.dataset.size).toBe('sm');
expect(toggle.getAttribute('aria-expanded')).toBe('false');
expect(toggle.className).toContain('ml-3.5');
expect(toggle.querySelector('p')).toBeNull();
});
});
+194 -218
View File
@@ -11,7 +11,6 @@ import {
Flag,
GitBranch,
type LucideIcon,
MessageSquare,
Play,
StickyNote,
Workflow,
@@ -21,9 +20,11 @@ import { useTranslation } from 'react-i18next';
import { useDispatch, useSelector } from 'react-redux';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { EmptyState } from '@/components/ui/empty-state';
import { SectionHeader } from '@/components/ui/section-header';
import { SheetTitle } from '@/components/ui/sheet';
import { Spinner } from '@/components/ui/spinner';
import {
MessageScroller,
@@ -50,7 +51,6 @@ import {
resendQuery,
resetWorkflowPreview,
selectActiveNodeId,
setPreviewOpen,
selectWorkflowExecutionSteps,
selectWorkflowPreviewQueries,
selectWorkflowPreviewStatus,
@@ -94,7 +94,64 @@ const NODE_COLORS: Record<string, string> = {
code: 'text-info',
};
function ExecutionDetails({
/**
* A step row under an answer, like Reasoning in chat (AnswerFlow): a ghost
* button whose muted icon sits on the answer's ml-6 column, then the panel it
* opens in place on that column.
*/
function StepDisclosure({
icon: Icon,
label,
count,
isOpen,
onToggle,
children,
}: {
icon: LucideIcon;
label: string;
count?: string;
isOpen: boolean;
onToggle: () => void;
children: React.ReactNode;
}) {
return (
<div className="my-2 flex w-full flex-col">
<Button
type="button"
variant="ghost"
size="sm"
onClick={onToggle}
aria-expanded={isOpen}
// ml-3.5 plus size sm's own has-[>svg]:px-2.5 puts the icon on the
// answer's ml-6 text column.
className="ml-3.5 w-fit max-w-full justify-start"
>
<Icon className="text-muted-foreground" aria-hidden />
<span className="text-muted-foreground min-w-0 truncate">{label}</span>
{count && (
<span className="text-muted-foreground/70 font-normal">{count}</span>
)}
<ChevronDown
aria-hidden
className={cn(
'text-muted-foreground shrink-0 transition-transform duration-200',
isOpen && 'rotate-180',
)}
/>
</Button>
<div
className={cn(
'mr-5 ml-6 grid transition-[grid-template-rows,opacity] duration-300 ease-out',
isOpen ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0',
)}
>
<div className="min-h-0 overflow-hidden">{children}</div>
</div>
</div>
);
}
export function ExecutionDetails({
steps,
nodes,
isOpen,
@@ -122,153 +179,121 @@ function ExecutionDetails({
};
return (
<div className="mb-4 flex w-full flex-col flex-wrap items-start self-start lg:flex-nowrap">
<div className="my-2 flex flex-row items-center justify-center gap-3">
<div className="flex h-[26px] w-[30px] items-center justify-center">
<Workflow className="text-muted-foreground size-5" />
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={onToggle}
className="-ml-2.5"
>
<p className="text-base font-semibold">
{t('agents.workflow.preview.executionDetails')}
<span className="text-muted-foreground ml-1.5 text-sm font-normal">
{t('agents.workflow.preview.stepCount', {
count: completedSteps.length,
})}
</span>
</p>
<ChevronDown
className={cn(
'transition-transform duration-200',
isOpen && 'rotate-180',
)}
/>
</Button>
</div>
<div
className={cn(
'ml-3 grid w-full transition-[grid-template-rows,opacity] duration-300 ease-out',
isOpen ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0',
)}
>
<div className="overflow-hidden">
<div className="flex flex-col gap-2 pr-2">
{completedSteps.map((step, stepIndex) => {
const node = nodes.find((n) => n.id === step.nodeId);
const displayName =
node?.title || node?.data?.title || step.nodeTitle;
const StepIcon = NODE_ICONS[step.nodeType] || Circle;
const stateVars = step.stateDelta
? Object.entries(step.stateDelta).filter(
([key]) => !['query', 'chat_history'].includes(key),
)
: [];
<StepDisclosure
icon={Workflow}
label={t('agents.workflow.preview.executionDetails')}
count={t('agents.workflow.preview.stepCount', {
count: completedSteps.length,
})}
isOpen={isOpen}
onToggle={onToggle}
>
<div className="flex flex-col gap-2 pt-1">
{completedSteps.map((step, stepIndex) => {
const node = nodes.find((n) => n.id === step.nodeId);
const displayName =
node?.title || node?.data?.title || step.nodeTitle;
const StepIcon = NODE_ICONS[step.nodeType] || Circle;
const stateVars = step.stateDelta
? Object.entries(step.stateDelta).filter(
([key]) => !['query', 'chat_history'].includes(key),
)
: [];
const truncateText = (text: string, maxLength: number) => {
if (text.length <= maxLength) return text;
return text.slice(0, maxLength) + '...';
};
const hasOutput =
step.output !== undefined &&
step.output !== null &&
formatValue(step.output) !== '';
const formattedOutput = hasOutput ? formatValue(step.output) : '';
const truncateText = (text: string, maxLength: number) => {
if (text.length <= maxLength) return text;
return text.slice(0, maxLength) + '...';
};
const hasOutput =
step.output !== undefined &&
step.output !== null &&
formatValue(step.output) !== '';
const formattedOutput = hasOutput ? formatValue(step.output) : '';
return (
<div
key={step.nodeId}
ref={(el) => {
if (el && stepRefs) stepRefs.current.set(step.nodeId, el);
}}
className="bg-muted rounded-xl p-3"
>
<div className="flex items-center gap-2 text-sm">
<span className="text-muted-foreground flex size-5 shrink-0 items-center justify-center text-xs font-medium">
{stepIndex + 1}.
</span>
<div
className={cn(
'shrink-0',
NODE_COLORS[step.nodeType] || NODE_COLORS.state,
)}
>
<StepIcon className="size-3" />
</div>
<span className="text-foreground min-w-0 truncate font-medium">
{displayName}
</span>
<div className="ml-auto shrink-0">
{step.status === 'completed' && (
<CircleCheck className="text-success size-4" />
)}
{step.status === 'failed' && (
<CircleX className="text-destructive size-4" />
)}
</div>
</div>
{(hasOutput || step.error || stateVars.length > 0) && (
<div className="mt-3 flex flex-col gap-2 text-sm">
{hasOutput && (
<div className="bg-muted rounded-lg p-2">
<span className="text-muted-foreground font-medium">
{t('agents.workflow.preview.outputLabel')}{' '}
</span>
<span className="text-foreground wrap-break-word whitespace-pre-wrap">
{truncateText(formattedOutput, 300)}
</span>
</div>
)}
{step.error && (
<Alert variant="destructive" role="status">
<CircleAlert />
<AlertDescription>
<span className="font-medium">
{t('agents.workflow.preview.errorLabel')}{' '}
</span>
<span className="wrap-break-word whitespace-pre-wrap">
{step.error}
</span>
</AlertDescription>
</Alert>
)}
{stateVars.length > 0 && (
<div className="flex flex-wrap gap-2">
{stateVars.map(([key, value]) => (
<span
key={key}
className="bg-muted inline-flex items-center rounded-lg px-2 py-1 text-xs"
>
<span className="text-muted-foreground max-w-[100px] truncate font-medium">
{key}:
</span>
<span
className="text-foreground ml-1 max-w-[200px] truncate"
title={formatValue(value)}
>
{truncateText(formatValue(value), 50)}
</span>
</span>
))}
</div>
)}
</div>
// A step is a place you read inside: a subtle panel on the
// drawer's background, with its output in a filled well.
return (
<Card
key={step.nodeId}
ref={(el: HTMLDivElement | null) => {
if (el && stepRefs) stepRefs.current.set(step.nodeId, el);
}}
variant="subtle"
padding="sm"
>
<div className="flex items-center gap-2">
<span className="text-muted-foreground flex size-5 shrink-0 items-center justify-center text-xs font-medium">
{stepIndex + 1}.
</span>
<StepIcon
aria-hidden
className={cn(
'size-3 shrink-0',
NODE_COLORS[step.nodeType] || NODE_COLORS.state,
)}
/>
<span className="text-foreground min-w-0 truncate font-medium">
{displayName}
</span>
<div className="ml-auto shrink-0">
{step.status === 'completed' && (
<CircleCheck className="text-success size-4" />
)}
{step.status === 'failed' && (
<CircleX className="text-destructive size-4" />
)}
</div>
);
})}
</div>
</div>
</div>
{hasOutput && (
<Card variant="filled" padding="sm">
<p className="wrap-break-word whitespace-pre-wrap">
<span className="text-muted-foreground font-medium">
{t('agents.workflow.preview.outputLabel')}{' '}
</span>
<span className="text-foreground">
{truncateText(formattedOutput, 300)}
</span>
</p>
</Card>
)}
{step.error && (
<Alert variant="destructive" role="status">
<CircleAlert />
<AlertDescription>
<span className="font-medium">
{t('agents.workflow.preview.errorLabel')}{' '}
</span>
<span className="wrap-break-word whitespace-pre-wrap">
{step.error}
</span>
</AlertDescription>
</Alert>
)}
{stateVars.length > 0 && (
<div className="flex flex-wrap gap-2">
{stateVars.map(([key, value]) => (
// eslint-disable-next-line shadcn/no-restyle -- state keys and values are serialised by the app, so the chip is set in mono
<Badge key={key} variant="neutral" className="font-mono">
<span className="max-w-[100px] truncate">{key}:</span>
<span
className="text-foreground max-w-[200px] truncate"
title={formatValue(value)}
>
{truncateText(formatValue(value), 50)}
</span>
</Badge>
))}
</div>
)}
</Card>
);
})}
</div>
</div>
</StepDisclosure>
);
}
function RunArtifactsSection({
export function RunArtifactsSection({
workflowRunId,
isOpen,
onToggle,
@@ -281,47 +306,21 @@ function RunArtifactsSection({
}) {
const { t } = useTranslation();
return (
<div className="mb-4 flex w-full flex-col flex-wrap items-start self-start lg:flex-nowrap">
<div className="my-2 flex flex-row items-center justify-center gap-3">
<div className="flex h-[26px] w-[30px] items-center justify-center">
<FileBox className="text-muted-foreground size-5" />
</div>
<Button
type="button"
variant="ghost"
size="sm"
onClick={onToggle}
className="-ml-2.5"
>
<p className="text-base font-semibold">
{t('agents.workflow.preview.artifacts')}
</p>
<ChevronDown
className={cn(
'transition-transform duration-200',
isOpen && 'rotate-180',
)}
<StepDisclosure
icon={FileBox}
label={t('agents.workflow.preview.artifacts')}
isOpen={isOpen}
onToggle={onToggle}
>
<div className="max-h-[480px] overflow-y-auto pt-1">
{isOpen && (
<WorkflowRunArtifacts
workflowRunId={workflowRunId}
inProgress={runInProgress}
/>
</Button>
</div>
<div
className={cn(
'ml-3 grid w-full transition-[grid-template-rows,opacity] duration-300 ease-out',
isOpen ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0',
)}
>
<div className="overflow-hidden">
<div className="max-h-[480px] overflow-y-auto pr-2">
{isOpen && (
<WorkflowRunArtifacts
workflowRunId={workflowRunId}
inProgress={runInProgress}
/>
)}
</div>
</div>
</div>
</div>
</StepDisclosure>
);
}
@@ -409,11 +408,11 @@ export function WorkflowMiniMap({
className={cn(
'h-12 w-full justify-start disabled:opacity-100',
isActive
? 'bg-primary/10 ring-primary hover:bg-primary/10 dark:bg-primary/10 dark:hover:bg-primary/10 ring-2'
? 'bg-secondary ring-primary hover:bg-secondary dark:bg-secondary dark:hover:bg-secondary ring-2'
: status === 'completed'
? 'border-success/50 bg-success/10 hover:bg-success/10 dark:border-success/50 dark:bg-success/10 dark:hover:bg-success/10'
: status === 'running'
? 'border-primary/50 bg-primary/10 hover:bg-primary/10 dark:border-primary/50 dark:bg-primary/10 dark:hover:bg-primary/10 animate-pulse'
? 'border-primary/50 bg-secondary hover:bg-secondary dark:border-primary/50 dark:bg-secondary dark:hover:bg-secondary animate-pulse'
: status === 'failed'
? 'border-destructive/50 bg-destructive/10 hover:bg-destructive/10 dark:border-destructive/50 dark:bg-destructive/10 dark:hover:bg-destructive/10'
: 'border-border bg-muted hover:bg-muted dark:border-border dark:bg-muted dark:hover:bg-muted',
@@ -596,12 +595,10 @@ export default function WorkflowPreview({
useEffect(() => {
dispatch(resetWorkflowPreview());
dispatch(setPreviewOpen(true));
return () => {
if (fetchStream.current) fetchStream.current.abort();
handleWorkflowPreviewAbort();
dispatch(resetWorkflowPreview());
dispatch(setPreviewOpen(false));
};
}, [dispatch]);
@@ -616,28 +613,7 @@ export default function WorkflowPreview({
queries.length > 0 ? queries[queries.length - 1].executionSteps || [] : [];
return (
<div className="bg-card flex h-full flex-col">
<div className="border-border flex h-[77px] items-center justify-between border-b px-6">
<div className="flex items-center gap-3">
<div className="bg-muted text-muted-foreground flex items-center justify-center rounded-full p-3">
<Play className="size-4" />
</div>
<div>
<SheetTitle>{t('agents.form.sections.preview')}</SheetTitle>
<p className="text-muted-foreground max-w-md truncate text-xs">
{workflowData.name}
{workflowData.description && ` - ${workflowData.description}`}
</p>
</div>
</div>
{status === 'loading' && (
<span className="text-primary flex items-center gap-1 text-xs">
<Spinner size="xs" />
{t('agents.schedules.status.running')}
</span>
)}
</div>
<div className="flex h-full min-h-0 flex-col">
<div className="flex min-h-0 flex-1">
<div className="border-border flex w-64 shrink-0 flex-col border-r">
<div className="flex items-center justify-between px-4 py-3">
@@ -662,13 +638,12 @@ export default function WorkflowPreview({
<div className="flex min-w-0 flex-1 flex-col">
<div className="relative min-h-0 flex-1">
{queries.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center px-4">
<div className="bg-muted mb-2 flex size-14 shrink-0 items-center justify-center rounded-xl">
<MessageSquare className="text-muted-foreground size-6" />
</div>
<p className="text-foreground text-xl font-semibold">
{t('agents.workflow.preview.emptyTitle')}
</p>
<div className="flex h-full items-center justify-center px-4">
<EmptyState
size="sm"
illustration="none"
title={t('agents.workflow.preview.emptyTitle')}
/>
</div>
) : (
<MessageScrollerProvider autoScroll>
@@ -788,11 +763,12 @@ export default function WorkflowPreview({
</MessageScrollerProvider>
)}
</div>
<div className="bg-card flex w-full flex-col gap-2 px-4 pt-2 pb-4">
<div className="flex w-full flex-col gap-2 px-4 pt-2 pb-4">
{sendBlockedMessage && (
<p className="text-destructive text-xs" role="alert">
{t(sendBlockedMessage)}
</p>
<Alert variant="destructive">
<CircleAlert />
<AlertDescription>{t(sendBlockedMessage)}</AlertDescription>
</Alert>
)}
<MessageInput
onSubmit={(text) => handleQuestionSubmission(text)}
@@ -66,7 +66,7 @@ export default function NodeDocumentsControl({
return (
<FormField label={label} hint={helpText} id={fieldId} float={false}>
{/* The track is a plain wrapper: ToggleGroup takes layout only. */}
<div className="border-border bg-card rounded-xl border p-1">
<div className="bg-muted rounded-full p-1">
<ToggleGroup
id={fieldId}
type="single"
@@ -121,7 +121,7 @@ export default function NodeDocumentsControl({
onClick={addRef}
className="shrink-0"
>
<Plus className="size-3.5" />
<Plus />
{t('agents.form.buttons.add')}
</Button>
</div>
@@ -337,6 +337,8 @@ interface PromptTextAreaProps {
placeholder?: string;
rows?: number;
label?: string;
/** Surface behind the field, for the floating label's notch. */
labelSurface?: 'card' | 'background' | 'muted';
}
export default function PromptTextArea({
@@ -348,6 +350,7 @@ export default function PromptTextArea({
placeholder,
rows = 4,
label,
labelSurface = 'card',
}: PromptTextAreaProps) {
const { t } = useTranslation();
const textareaId = useId();
@@ -442,7 +445,7 @@ export default function PromptTextArea({
// so typing keeps filtering it.
const field = (
<PopoverAnchor asChild>
<div className="border-border focus-within:ring-ring bg-card relative rounded-xl border transition-shadow focus-within:ring-2">
<div className="border-border focus-within:border-ring focus-within:ring-ring/50 relative rounded-xl border transition-shadow focus-within:ring-3">
<div
ref={overlayRef}
aria-hidden
@@ -525,7 +528,7 @@ export default function PromptTextArea({
}}
>
{label ? (
<FormField label={label} id={textareaId}>
<FormField label={label} id={textareaId} labelSurface={labelSurface}>
{field}
</FormField>
) : (
@@ -0,0 +1,113 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
import WorkflowDetailsSheet from './WorkflowDetailsSheet';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const details = {
name: 'Helpdesk Triage',
description: 'Routes internal requests',
allowPromptOverride: false,
};
describe('WorkflowDetailsSheet', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
document.body.innerHTML = '';
});
const render = (props: { errors?: string[]; saving?: boolean } = {}) => {
const handlers = { onOpenChange: vi.fn(), onSave: vi.fn() };
act(() => {
root.render(
<WorkflowDetailsSheet
open
details={details}
currentImage=""
saving={props.saving ?? false}
errors={props.errors ?? []}
{...handlers}
/>,
);
});
return handlers;
};
const button = (name: string) =>
Array.from(document.querySelectorAll('button')).find(
(b) => b.textContent === name,
) as HTMLButtonElement;
const nameInput = () =>
document.querySelector('input[type="text"]') as HTMLInputElement;
const type = (input: HTMLInputElement, value: string) => {
const setter = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
'value',
)?.set;
act(() => {
setter?.call(input, value);
input.dispatchEvent(new Event('input', { bubbles: true }));
});
};
it('opens with the workflow details filled in', () => {
render();
expect(document.body.textContent).toContain(
'agents.workflow.builder.detailsTitle',
);
expect(nameInput().value).toBe('Helpdesk Triage');
expect(document.querySelector('textarea')?.value).toBe(
'Routes internal requests',
);
expect(document.querySelector('[role="switch"]')).not.toBeNull();
});
it('keeps Save disabled until something changes, then saves the edits', () => {
const { onSave } = render();
const save = button('agents.form.buttons.save');
expect(save.disabled).toBe(true);
type(nameInput(), 'Helpdesk Router');
expect(save.disabled).toBe(false);
act(() => save.click());
expect(onSave).toHaveBeenCalledWith({
name: 'Helpdesk Router',
description: 'Routes internal requests',
allowPromptOverride: false,
imageFile: null,
});
});
it('closes on Cancel without saving', () => {
const { onOpenChange, onSave } = render();
type(nameInput(), 'Something else');
act(() => button('agents.form.buttons.cancel').click());
expect(onOpenChange).toHaveBeenCalledWith(false);
expect(onSave).not.toHaveBeenCalled();
});
it('lists save errors in an alert inside the sheet', () => {
render({ errors: ['Workflow must have an end node'] });
const alert = document.querySelector('[role="alert"]');
expect(alert?.textContent).toContain('agents.workflow.builder.unableSave');
expect(alert?.textContent).toContain('Workflow must have an end node');
});
});
@@ -0,0 +1,226 @@
import { CircleX } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { FormField } from '@/components/ui/form-field';
import { Input } from '@/components/ui/input';
import { SectionHeader } from '@/components/ui/section-header';
import { Separator } from '@/components/ui/separator';
import { SettingRow } from '@/components/ui/setting-row';
import {
Sheet,
SheetContent,
SheetDescription,
SheetTitle,
} from '@/components/ui/sheet';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { FileUpload } from '../../../components/FileUpload';
export interface WorkflowDetailsValues {
name: string;
description: string;
allowPromptOverride: boolean;
}
export interface WorkflowDetailsSave extends WorkflowDetailsValues {
/** A newly picked avatar, or null to keep the current one. */
imageFile: File | null;
}
interface WorkflowDetailsSheetProps {
open: boolean;
onOpenChange: (open: boolean) => void;
/** The builder's current values; the sheet edits a copy of them. */
details: WorkflowDetailsValues;
/** The saved avatar's URL, shown in the tile until a new one is picked. */
currentImage: string;
saving: boolean;
/** Errors from the last save started here, shown at the top of the body. */
errors: string[];
onSave: (values: WorkflowDetailsSave) => void;
}
/**
* The workflow's name, description, avatar and prompt-override setting, in a
* right drawer laid out like the classic agent's Basics on a phone.
*
* The sheet edits a copy: Cancel, the X, Escape and the scrim drop the edits,
* and Save hands them to the builder, which saves the workflow.
*
* Args:
* open: Whether the sheet is open.
* onOpenChange: Opens or closes the sheet.
* details: The values to start from each time it opens.
* currentImage: The saved avatar's URL.
* saving: Whether a save started here is running.
* errors: Errors from that save.
* onSave: Receives the edited values.
*/
export default function WorkflowDetailsSheet({
open,
onOpenChange,
details,
currentImage,
saving,
errors,
onSave,
}: WorkflowDetailsSheetProps) {
const { t } = useTranslation();
const [draft, setDraft] = useState<WorkflowDetailsValues>(details);
const [imageFile, setImageFile] = useState<File | null>(null);
// Start from the builder's values every time the sheet opens.
useEffect(() => {
if (!open) return;
setDraft(details);
setImageFile(null);
// Only on open: later changes to `details` come from this sheet's own save.
}, [open]);
const dirty =
imageFile !== null ||
draft.name !== details.name ||
draft.description !== details.description ||
draft.allowPromptOverride !== details.allowPromptOverride;
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent
side="right"
size="default"
className="p-0"
closeLabel={t('agents.close')}
>
<div className="flex min-h-0 flex-1 flex-col">
{/* pr-12 keeps the header clear of the close X at top-2 right-2. */}
<div className="flex flex-col gap-1 px-6 pt-6 pr-12 pb-4">
<SheetTitle>{t('agents.workflow.builder.detailsTitle')}</SheetTitle>
<SheetDescription>
{t('agents.workflow.builder.detailsDescription')}
</SheetDescription>
</div>
<Separator />
<div className="flex min-h-0 flex-1 flex-col gap-6 overflow-y-auto px-6 py-6">
{errors.length > 0 && (
<Alert variant="destructive">
<CircleX />
<AlertTitle>
{t('agents.workflow.builder.unableSave')}
</AlertTitle>
<AlertDescription>
<ul className="flex list-inside list-disc flex-col gap-1 wrap-break-word">
{errors.map((error, index) => (
<li key={index}>{error}</li>
))}
</ul>
</AlertDescription>
</Alert>
)}
<Card variant="subtle" padding="lg" className="gap-5">
<SectionHeader
as="h3"
size="xs"
title={t('agents.form.sections.basics')}
/>
{/* The phone layout of classic Basics: the avatar beside Name,
Description across the row. */}
<div className="grid grid-cols-[auto_1fr] items-center gap-x-4 gap-y-5">
<FileUpload
showPreview
size="tile"
tileSize="fixed"
currentImage={currentImage || undefined}
onUpload={(files) => setImageFile(files[0] ?? null)}
onRemove={() => setImageFile(null)}
uploadText={t('agents.form.labels.avatar')}
/>
<FormField
labelSurface="background"
label={t('agents.form.labels.name')}
>
<Input
shape="pill"
type="text"
value={draft.name}
placeholder={t(
'agents.workflow.builder.workflowNamePlaceholder',
)}
onChange={(e) =>
setDraft({ ...draft, name: e.target.value })
}
/>
</FormField>
<FormField
labelSurface="background"
label={t('agents.form.labels.description')}
className="col-span-2"
>
<Textarea
size="lg"
className="h-32"
placeholder={t(
'agents.workflow.builder.workflowDescriptionPlaceholder',
)}
value={draft.description}
onChange={(e) =>
setDraft({ ...draft, description: e.target.value })
}
/>
</FormField>
</div>
</Card>
<Card variant="subtle" padding="lg" className="gap-5">
<SectionHeader
as="h3"
size="xs"
title={t('agents.form.sections.advanced')}
/>
<SettingRow
label={t('agents.form.advanced.systemPromptOverride')}
description={t(
'agents.form.advanced.systemPromptOverrideDescription',
)}
htmlFor="workflow-system-prompt-override"
>
<Switch
id="workflow-system-prompt-override"
checked={draft.allowPromptOverride}
onCheckedChange={(checked) =>
setDraft({ ...draft, allowPromptOverride: checked })
}
/>
</SettingRow>
</Card>
</div>
<Separator />
<div className="flex justify-end gap-3 px-6 py-4">
<Button
type="button"
variant="ghost"
size="lg"
shape="pill"
onClick={() => onOpenChange(false)}
>
{t('agents.form.buttons.cancel')}
</Button>
<Button
type="button"
size="lg"
shape="pill"
disabled={!dirty}
loading={saving}
onClick={() => onSave({ ...draft, imageFile })}
>
{t('agents.form.buttons.save')}
</Button>
</div>
</div>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';
import { NODE_TONES, nodeToneClass, TONE_CLASSES } from './nodeTones';
describe('nodeTones', () => {
it('maps every node type to one tone', () => {
expect(NODE_TONES).toEqual({
start: 'success',
agent: 'primary',
end: 'success',
note: 'warning',
state: 'info',
condition: 'warning',
code: 'info',
});
});
it('never paints End red', () => {
expect(nodeToneClass('end')).not.toMatch(/destructive/);
expect(nodeToneClass('end')).toBe('bg-success/10 text-success');
});
it('uses the brand soft fill for primary, not bg-primary/10', () => {
expect(TONE_CLASSES.primary).toBe('bg-secondary text-secondary-foreground');
expect(nodeToneClass('agent')).toBe(TONE_CLASSES.primary);
});
it('gives status tones their /10 fill and text', () => {
expect(TONE_CLASSES.info).toBe('bg-info/10 text-info');
expect(TONE_CLASSES.warning).toBe('bg-warning/10 text-warning');
});
it('falls back to a neutral tint for an unknown type', () => {
expect(nodeToneClass('mystery')).toBe(
'bg-muted-foreground/15 text-muted-foreground',
);
});
});
+71
View File
@@ -0,0 +1,71 @@
import {
Bot,
CodeXml,
Database,
Flag,
GitBranch,
Play,
StickyNote,
type LucideIcon,
} from 'lucide-react';
/** The node types the builder draws. */
export type WorkflowNodeType =
'start' | 'agent' | 'end' | 'note' | 'state' | 'condition' | 'code';
/** A node's colour role: the brand tint or one status tint. */
export type NodeTone = 'primary' | 'success' | 'warning' | 'info';
/**
* One colour per node type, read by the palette, every canvas node and the
* settings panel's header. Start and End share `success` as the two ends of
* the flow; `destructive` is kept for errors, so End is never red.
*/
export const NODE_TONES: Record<WorkflowNodeType, NodeTone> = {
start: 'success',
agent: 'primary',
end: 'success',
note: 'warning',
state: 'info',
condition: 'warning',
code: 'info',
};
// Whole class strings per tone so Tailwind sees them. Brand is the soft
// `secondary` fill (never bg-primary/10); status tones are the /10 fill.
export const TONE_CLASSES: Record<NodeTone, string> = {
primary: 'bg-secondary text-secondary-foreground',
success: 'bg-success/10 text-success',
warning: 'bg-warning/10 text-warning',
info: 'bg-info/10 text-info',
};
const NEUTRAL_TONE_CLASS = 'bg-muted-foreground/15 text-muted-foreground';
/**
* The icon-square classes for a node type.
*
* Args:
* type: The node's type.
*
* Returns:
* The tone's fill and text classes, or a neutral tint for an unknown type.
*/
export function nodeToneClass(type: string | undefined): string {
const tone = NODE_TONES[type as WorkflowNodeType];
return tone ? TONE_CLASSES[tone] : NEUTRAL_TONE_CLASS;
}
/** Each node type's icon and its name's locale key. */
export const NODE_META: Record<
WorkflowNodeType,
{ icon: LucideIcon; labelKey: string }
> = {
start: { icon: Play, labelKey: 'agents.workflow.nodes.start' },
agent: { icon: Bot, labelKey: 'agents.workflow.builder.aiAgent' },
end: { icon: Flag, labelKey: 'agents.workflow.nodes.end' },
note: { icon: StickyNote, labelKey: 'agents.workflow.nodes.note' },
state: { icon: Database, labelKey: 'agents.workflow.nodes.setState' },
condition: { icon: GitBranch, labelKey: 'agents.workflow.nodes.condition' },
code: { icon: CodeXml, labelKey: 'agents.workflow.nodes.code' },
};
+14 -34
View File
@@ -3,11 +3,14 @@ import { Handle, Position } from 'reactflow';
import { cn } from '@/lib/utils';
import { nodeToneClass, type WorkflowNodeType } from '../nodeTones';
interface BaseNodeProps {
title: string;
children?: ReactNode;
selected?: boolean;
type?: 'start' | 'end' | 'default' | 'state' | 'agent' | 'condition' | 'code';
/** The node type; picks the icon square's tone from the shared map. */
type: WorkflowNodeType;
icon?: ReactNode;
handles?: {
source?: boolean;
@@ -15,47 +18,25 @@ interface BaseNodeProps {
};
}
/**
* The pill every canvas node but Note and If / Else is drawn as: a tinted
* icon, the title, a muted meta block and the handles. Selected is the focus
* ring look (`border-primary ring-3 ring-ring/50`), with no scale, so the
* node stays put under the pointer.
*/
export const BaseNode: React.FC<BaseNodeProps> = ({
title,
children,
selected,
type = 'default',
type,
icon,
handles = { source: true, target: true },
}) => {
let bgColor = 'bg-card';
let borderColor = 'border-border';
let iconBg = 'bg-muted';
let iconColor = 'text-muted-foreground';
if (selected) {
borderColor = 'border-primary ring-2 ring-primary';
}
if (type === 'start') {
iconBg = 'bg-success/10';
iconColor = 'text-success';
} else if (type === 'end') {
iconBg = 'bg-destructive/10';
iconColor = 'text-destructive';
} else if (type === 'state') {
iconBg = 'bg-muted';
iconColor = 'text-muted-foreground';
} else if (type === 'condition') {
iconBg = 'bg-warning/10';
iconColor = 'text-warning';
} else if (type === 'code') {
iconBg = 'bg-info/10';
iconColor = 'text-info';
}
return (
<div
className={cn(
'rounded-full border shadow-md transition hover:shadow-lg',
bgColor,
borderColor,
selected && 'scale-105',
'bg-card rounded-full border shadow-md transition hover:shadow-lg',
selected ? 'border-primary ring-ring/50 ring-3' : 'border-border',
'max-w-[250px] min-w-[180px]',
)}
>
@@ -72,8 +53,7 @@ export const BaseNode: React.FC<BaseNodeProps> = ({
<div
className={cn(
'flex size-10 shrink-0 items-center justify-center rounded-full',
iconBg,
iconColor,
nodeToneClass(type),
)}
>
{icon}
@@ -5,9 +5,7 @@ import { NodeProps } from 'reactflow';
import { CodeNodeConfig } from '../../types/workflow';
import { BaseNode } from './BaseNode';
// Variable names are the user's own text: React escapes on render.
const NO_ESCAPE = { interpolation: { escapeValue: false } } as const;
import OutputVariableLine from './OutputVariableLine';
type CodeNodeData = {
title?: string;
@@ -46,18 +44,7 @@ const CodeNode = ({ data, selected }: NodeProps<CodeNodeData>) => {
</div>
)}
{config.output_variable && (
<div
className="text-muted-foreground truncate text-xs"
title={t('agents.workflow.nodes.output', {
...NO_ESCAPE,
variable: config.output_variable,
})}
>
{t('agents.workflow.nodes.output', {
...NO_ESCAPE,
variable: config.output_variable,
})}
</div>
<OutputVariableLine variable={config.output_variable} />
)}
</div>
</BaseNode>
@@ -6,6 +6,7 @@ import { Handle, NodeProps, Position } from 'reactflow';
import { cn } from '@/lib/utils';
import { ConditionCase } from '../../types/workflow';
import { nodeToneClass } from '../nodeTones';
type ConditionNodeData = {
label?: string;
@@ -44,7 +45,7 @@ const ConditionNode = ({ data, selected }: NodeProps<ConditionNodeData>) => {
className={cn(
'bg-card relative rounded-2xl border shadow-md transition',
selected
? 'border-primary ring-primary scale-105 ring-2'
? 'border-primary ring-ring/50 ring-3'
: 'border-border hover:shadow-lg',
)}
style={{ minWidth: 180, maxWidth: 220, height }}
@@ -57,7 +58,12 @@ const ConditionNode = ({ data, selected }: NodeProps<ConditionNodeData>) => {
/>
<div className="flex items-center gap-3 px-3 py-2">
<div className="bg-warning/10 text-warning flex size-9 shrink-0 items-center justify-center rounded-full">
<div
className={cn(
'flex size-9 shrink-0 items-center justify-center rounded-full',
nodeToneClass('condition'),
)}
>
<GitBranch className="size-3.5" />
</div>
<div className="min-w-0 flex-1 pr-2">
@@ -69,8 +75,8 @@ const ConditionNode = ({ data, selected }: NodeProps<ConditionNodeData>) => {
</div>
<div className="text-muted-foreground text-xs">
{data.config?.mode === 'advanced'
? t('agents.workflow.nodes.modeAdvanced')
: t('agents.workflow.nodes.modeSimple')}
? t('agents.workflow.builder.modeAdvanced')
: t('agents.workflow.builder.modeSimple')}
</div>
</div>
</div>
@@ -0,0 +1,25 @@
import { ArrowRight } from 'lucide-react';
import { useTranslation } from 'react-i18next';
// Variable names are the user's own text: React escapes on render.
const NO_ESCAPE = { interpolation: { escapeValue: false } } as const;
/**
* A node's output variable on the canvas: an arrow, then the name in mono,
* because it is what the next node reads.
*
* Args:
* variable: The output variable's name.
*/
export default function OutputVariableLine({ variable }: { variable: string }) {
const { t } = useTranslation();
return (
<div
className="text-muted-foreground flex min-w-0 items-center gap-1 text-xs"
title={t('agents.workflow.nodes.output', { ...NO_ESCAPE, variable })}
>
<ArrowRight className="size-3 shrink-0" aria-hidden="true" />
<span className="truncate font-mono">{variable}</span>
</div>
);
}
+20 -31
View File
@@ -4,14 +4,14 @@ import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
import { nodeToneClass } from '../nodeTones';
import { useModelDisplayName } from '../WorkflowModelsContext';
import { BaseNode } from './BaseNode';
import OutputVariableLine from './OutputVariableLine';
import CodeNode from './CodeNode';
import ConditionNode from './ConditionNode';
import SetStateNode from './SetStateNode';
// Variable names are the user's own text: React escapes on render.
const NO_ESCAPE = { interpolation: { escapeValue: false } } as const;
export const StartNode = memo(function StartNode({
selected,
}: {
@@ -73,6 +73,12 @@ export const AgentNode = memo(function AgentNode({
const { t } = useTranslation();
const title = data.title || data.label || t('agents.workflow.nodes.agent');
const config = data.config || {};
const modelName = useModelDisplayName(config.model_id);
const agentType =
config.agent_type === 'research' || config.agent_type === 'classic'
? t(`agents.form.agentTypes.${config.agent_type}`)
: config.agent_type;
const meta = [agentType, modelName].filter(Boolean).join(' · ');
return (
<BaseNode
title={title}
@@ -81,35 +87,13 @@ export const AgentNode = memo(function AgentNode({
icon={<Bot className="size-4" />}
>
<div className="flex flex-col gap-1">
{config.agent_type && (
<div
className="text-muted-foreground truncate text-xs"
title={config.agent_type}
>
{config.agent_type}
</div>
)}
{config.model_id && (
<div
className="text-primary truncate text-xs"
title={config.model_id}
>
{config.model_id}
{meta && (
<div className="text-muted-foreground truncate text-xs" title={meta}>
{meta}
</div>
)}
{config.output_variable && (
<div
className="text-muted-foreground truncate text-xs"
title={t('agents.workflow.nodes.output', {
...NO_ESCAPE,
variable: config.output_variable,
})}
>
{t('agents.workflow.nodes.output', {
...NO_ESCAPE,
variable: config.output_variable,
})}
</div>
<OutputVariableLine variable={config.output_variable} />
)}
</div>
</BaseNode>
@@ -138,12 +122,17 @@ export const NoteNode = memo(function NoteNode({
// over it as a flat gradient, so the canvas grid doesn't show through.
'bg-card from-warning/10 to-warning/10 max-w-[250px] rounded-3xl border bg-linear-to-b px-5 py-3 shadow-md transition',
selected
? 'border-warning ring-warning scale-105 ring-2'
? 'border-warning ring-warning/50 ring-3'
: 'border-warning/50 hover:shadow-lg',
)}
>
<div className="flex items-start gap-3">
<div className="bg-warning/15 text-warning flex size-10 shrink-0 items-center justify-center rounded-full">
<div
className={cn(
'flex size-10 shrink-0 items-center justify-center rounded-full',
nodeToneClass('note'),
)}
>
<StickyNote className="size-4.5" />
</div>
<div className="min-w-0 flex-1">
@@ -0,0 +1,138 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { type Node } from 'reactflow';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
import AgentPanel from './AgentPanel';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const makeNode = (config: Record<string, unknown> = {}): Node => ({
id: 'agent_1',
type: 'agent',
position: { x: 0, y: 0 },
data: {
title: 'Classify',
config: {
agent_type: 'classic',
system_prompt: '',
prompt_template: '',
stream_to_user: true,
sources: [],
tools: [],
...config,
},
},
});
describe('AgentPanel', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
document.body.innerHTML = '';
});
const render = (node: Node, jsonSchemaText = '') => {
const onUpdate = vi.fn();
act(() => {
root.render(
<AgentPanel
node={node}
onUpdate={onUpdate}
nodes={[node]}
edges={[]}
availableModels={[]}
availableTools={[]}
sourceOptions={[]}
documentOptions={[]}
jsonSchemaText={jsonSchemaText}
jsonSchemaError={null}
modelSupportsStructuredOutput
onJsonSchemaChange={vi.fn()}
/>,
);
});
return { onUpdate };
};
const advancedToggle = () =>
Array.from(container.querySelectorAll('button')).find((b) =>
b.textContent?.includes('agents.workflow.builder.advancedSettings'),
) as HTMLButtonElement;
it('groups the fields under Model, Prompt, Knowledge and Output', () => {
render(makeNode());
const headings = Array.from(container.querySelectorAll('h3')).map(
(h) => h.textContent,
);
expect(headings).toEqual([
'agents.form.sections.model',
'agents.form.sections.prompt',
'agents.workflow.builder.knowledge',
'agents.workflow.builder.output',
]);
});
it('stacks Agent type and Model at full width in the narrow panel', () => {
render(makeNode());
const modelSection = container.querySelector('section')!;
expect(modelSection.querySelector('.grid')).toBeNull();
const triggers = modelSection.querySelectorAll('[role="combobox"]');
expect(triggers).toHaveLength(2);
triggers.forEach((trigger) =>
expect(trigger.className).toContain('w-full'),
);
});
it('streams through a Switch with a description, not a Checkbox', () => {
const { onUpdate } = render(makeNode());
expect(container.querySelector('[role="checkbox"]')).toBeNull();
const toggle = container.querySelector(
'[role="switch"]',
) as HTMLButtonElement;
expect(toggle.getAttribute('aria-checked')).toBe('true');
expect(container.textContent).toContain(
'agents.workflow.builder.streamToUserDescription',
);
act(() => toggle.click());
expect(onUpdate).toHaveBeenCalledWith({
config: expect.objectContaining({ stream_to_user: false }),
});
});
it('keeps advanced settings closed when none is set', () => {
render(makeNode());
const toggle = advancedToggle();
expect(toggle.getAttribute('aria-expanded')).toBe('false');
expect(container.textContent).not.toContain(
'agents.workflow.builder.filePassing',
);
act(() => toggle.click());
expect(toggle.getAttribute('aria-expanded')).toBe('true');
expect(container.textContent).toContain(
'agents.workflow.builder.filePassing',
);
});
it('opens advanced settings when one of them is set', () => {
render(makeNode({ file_passing: 'native' }));
expect(advancedToggle().getAttribute('aria-expanded')).toBe('true');
});
it('opens advanced settings when a schema is present', () => {
render(makeNode({ json_schema: { type: 'object' } }), '{"type":"object"}');
expect(advancedToggle().getAttribute('aria-expanded')).toBe('true');
});
});
@@ -0,0 +1,403 @@
import { ChevronRight } from 'lucide-react';
import { useId, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { type Edge, type Node } from 'reactflow';
import { Button } from '@/components/ui/button';
import { FormField } from '@/components/ui/form-field';
import { Input } from '@/components/ui/input';
import { MultiSelect } from '@/components/ui/multi-select';
import { SectionHeader } from '@/components/ui/section-header';
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { SettingRow, SettingRows } from '@/components/ui/setting-row';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
import { getToolDisplayName } from '../../../utils/toolUtils';
import NodeDocumentsControl from '../components/NodeDocumentsControl';
import PromptTextArea from '../components/PromptTextArea';
import {
DEFAULT_FILE_PASSING,
FILE_PASSING_OPTIONS,
FilePassing,
normalizeFilePassing,
} from '../documentConfig';
import { NO_ESCAPE, schemaErrorText, type UserTool } from '../workflowHelpers';
import { type NodePanelBodyProps } from './types';
import type { Model } from '../../../models/types';
interface AgentPanelProps extends NodePanelBodyProps {
nodes: Node[];
edges: Edge[];
availableModels: Model[];
availableTools: UserTool[];
sourceOptions: { value: string; label: string }[];
/** Upstream file variables the agent can read. */
documentOptions: { value: string; label: string }[];
/** The structured-output schema as typed. */
jsonSchemaText: string;
/** The schema's validation fragment, or null. */
jsonSchemaError: string | null;
/** False when the picked model can't return structured output. */
modelSupportsStructuredOutput: boolean;
onJsonSchemaChange: (text: string) => void;
}
const SCHEMA_PLACEHOLDER = `{
"type": "object",
"properties": {
"summary": { "type": "string" }
},
"required": ["summary"]
}`;
/**
* Whether any advanced setting (documents, file passing, structured output)
* differs from its default, so the disclosure opens on its own.
*
* Args:
* config: The agent node's config.
* jsonSchemaText: The schema as typed.
* jsonSchemaError: The schema's validation fragment, or null.
*
* Returns:
* True when the advanced settings should start open.
*/
export function hasAdvancedAgentSettings(
config: Record<string, unknown> | undefined,
jsonSchemaText: string,
jsonSchemaError: string | null,
): boolean {
const inputDocuments = config?.input_documents;
return (
(Array.isArray(inputDocuments) && inputDocuments.length > 0) ||
normalizeFilePassing(config?.file_passing) !== DEFAULT_FILE_PASSING ||
(config?.json_schema !== undefined && config?.json_schema !== null) ||
jsonSchemaText.trim() !== '' ||
Boolean(jsonSchemaError)
);
}
/**
* Settings for an AI Agent node, grouped Model, Prompt, Knowledge and Output
* like classic Overview, with documents, file passing and structured output
* under an Advanced settings disclosure.
*/
export default function AgentPanel({
node,
onUpdate,
nodes,
edges,
availableModels,
availableTools,
sourceOptions,
documentOptions,
jsonSchemaText,
jsonSchemaError,
modelSupportsStructuredOutput,
onJsonSchemaChange,
}: AgentPanelProps) {
const { t } = useTranslation();
const streamId = useId();
const config = node.data.config || {};
const [advancedOpen, setAdvancedOpen] = useState(() =>
hasAdvancedAgentSettings(config, jsonSchemaText, jsonSchemaError),
);
const updateConfig = (patch: Record<string, unknown>) =>
onUpdate({ config: { ...(node.data.config || {}), ...patch } });
const builtinModels = availableModels.filter((m) => m.source !== 'user');
const userModels = availableModels.filter((m) => m.source === 'user');
const schemaInvalid = jsonSchemaText.trim() !== '' && jsonSchemaError;
return (
<div className="flex flex-col gap-6">
<section className="flex flex-col gap-5">
<SectionHeader
as="h3"
size="xs"
title={t('agents.form.sections.model')}
/>
<div className="flex flex-col gap-5">
<FormField
label={t('agents.workflow.builder.agentType')}
labelSurface="background"
>
<Select
value={config.agent_type || 'classic'}
onValueChange={(value) => updateConfig({ agent_type: value })}
>
<SelectTrigger size="field" className="w-full">
<SelectValue
placeholder={t(
'agents.workflow.builder.agentTypePlaceholder',
)}
/>
</SelectTrigger>
<SelectContent>
<SelectItem value="classic">
{t('agents.form.agentTypes.classic')}
</SelectItem>
<SelectItem value="research">
{t('agents.form.agentTypes.research')}
</SelectItem>
</SelectContent>
</Select>
</FormField>
<FormField
label={t('agents.workflow.builder.model')}
labelSurface="background"
>
<Select
value={config.model_id || ''}
onValueChange={(value) =>
updateConfig({
model_id: value,
llm_name:
availableModels.find((m) => m.id === value)?.provider || '',
})
}
>
<SelectTrigger size="field" className="w-full">
<SelectValue
placeholder={t('agents.workflow.builder.modelPlaceholder')}
/>
</SelectTrigger>
<SelectContent>
{builtinModels.length > 0 && (
<SelectGroup>
<SelectLabel>
{t('settings.customModels.modelsGroup.builtin')}
</SelectLabel>
{builtinModels.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.display_name} · {model.provider}
</SelectItem>
))}
</SelectGroup>
)}
{userModels.length > 0 && (
<SelectGroup>
<SelectLabel>
{t('settings.customModels.modelsGroup.user')}
</SelectLabel>
{userModels.map((model) => (
<SelectItem key={model.id} value={model.id}>
{model.display_name} · {model.provider}
</SelectItem>
))}
</SelectGroup>
)}
</SelectContent>
</Select>
</FormField>
</div>
</section>
<section className="flex flex-col gap-5">
<SectionHeader
as="h3"
size="xs"
title={t('agents.form.sections.prompt')}
/>
<FormField
label={t('agents.workflow.builder.systemPrompt')}
labelSurface="background"
>
<Textarea
value={config.system_prompt ?? ''}
onChange={(e) => updateConfig({ system_prompt: e.target.value })}
rows={3}
placeholder={t('agents.workflow.builder.systemPromptPlaceholder')}
/>
</FormField>
<PromptTextArea
label={t('agents.workflow.builder.promptTemplate')}
labelSurface="background"
value={config.prompt_template || ''}
onChange={(val) => updateConfig({ prompt_template: val })}
nodes={nodes}
edges={edges}
selectedNodeId={node.id}
placeholder={t('agents.workflow.builder.promptTemplatePlaceholder', {
...NO_ESCAPE,
example: '{{ agent.variable }}',
})}
/>
</section>
<section className="flex flex-col gap-5">
<SectionHeader
as="h3"
size="xs"
title={t('agents.workflow.builder.knowledge')}
/>
<FormField
label={t('agents.workflow.builder.sources')}
labelSurface="background"
>
<MultiSelect
options={sourceOptions}
selected={config.sources || []}
onChange={(newSources) => updateConfig({ sources: newSources })}
placeholder={t('agents.form.placeholders.selectSources')}
searchPlaceholder={t('agents.form.sourcePopup.searchPlaceholder')}
emptyText={t('agents.form.sourcePopup.noOptionsMessage')}
/>
</FormField>
<FormField
label={t('agents.form.sections.tools')}
labelSurface="background"
>
<MultiSelect
options={availableTools.map((tool) => ({
value: tool.id,
label: getToolDisplayName(tool),
}))}
selected={config.tools || []}
onChange={(newTools) => updateConfig({ tools: newTools })}
placeholder={t('agents.form.placeholders.selectTools')}
searchPlaceholder={t('agents.form.toolsPopup.searchPlaceholder')}
emptyText={t('agents.form.toolsPopup.noOptionsMessage')}
/>
</FormField>
</section>
<section className="flex flex-col gap-5">
<SectionHeader
as="h3"
size="xs"
title={t('agents.workflow.builder.output')}
/>
<FormField
label={t('agents.workflow.builder.outputVariable')}
labelSurface="background"
>
<Input
type="text"
value={config.output_variable || ''}
onChange={(e) => updateConfig({ output_variable: e.target.value })}
placeholder={t('agents.workflow.builder.outputVariablePlaceholder')}
/>
</FormField>
<SettingRows>
<SettingRow
label={t('agents.workflow.builder.streamToUser')}
description={t('agents.workflow.builder.streamToUserDescription')}
htmlFor={streamId}
>
<Switch
id={streamId}
checked={config.stream_to_user ?? true}
onCheckedChange={(checked) =>
updateConfig({ stream_to_user: checked })
}
/>
</SettingRow>
</SettingRows>
</section>
<div className="flex flex-col gap-5">
<Button
type="button"
variant="link"
size="sm"
aria-expanded={advancedOpen}
onClick={() => setAdvancedOpen((open) => !open)}
className="-ml-3 w-fit justify-start"
>
<ChevronRight
aria-hidden="true"
className={cn(
'transition-transform duration-200',
advancedOpen && 'rotate-90',
)}
/>
{t('agents.workflow.builder.advancedSettings')}
</Button>
{advancedOpen && (
<div className="flex flex-col gap-5">
<NodeDocumentsControl
key={node.id}
value={config.input_documents ?? []}
onChange={(nextInputDocuments) =>
updateConfig({ input_documents: nextInputDocuments })
}
options={documentOptions}
label={t('agents.workflow.builder.documents')}
helpText={t('agents.workflow.builder.documentsHint')}
/>
<FormField
label={t('agents.workflow.builder.filePassing')}
hint={t('agents.workflow.builder.filePassingHint')}
labelSurface="background"
>
<Select
value={normalizeFilePassing(config.file_passing)}
onValueChange={(value) =>
updateConfig({ file_passing: value as FilePassing })
}
>
<SelectTrigger size="field" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FILE_PASSING_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{t(`agents.workflow.filePassing.${option.value}`, {
defaultValue: option.label,
})}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
<FormField
label={t('agents.workflow.builder.structuredOutput')}
labelSurface="background"
hint={
[
!modelSupportsStructuredOutput
? t('agents.workflow.builder.modelNoStructuredOutput')
: null,
jsonSchemaText.trim() !== '' && !jsonSchemaError
? t('agents.workflow.builder.validSchema')
: null,
]
.filter(Boolean)
.join(' ') || undefined
}
error={
schemaInvalid
? t('agents.workflow.builder.invalidSchema', {
...NO_ESCAPE,
error: schemaErrorText(t, jsonSchemaError),
})
: undefined
}
>
<Textarea
value={jsonSchemaText}
onChange={(e) => onJsonSchemaChange(e.target.value)}
className="font-mono"
rows={4}
placeholder={SCHEMA_PLACEHOLDER}
/>
</FormField>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,152 @@
import { useTranslation } from 'react-i18next';
import { FormField } from '@/components/ui/form-field';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import NodeDocumentsControl from '../components/NodeDocumentsControl';
import { NO_ESCAPE, schemaErrorText } from '../workflowHelpers';
import { type NodePanelBodyProps } from './types';
interface CodePanelProps extends NodePanelBodyProps {
/** Upstream file variables the code can take as inputs. */
documentOptions: { value: string; label: string }[];
/** The structured-output schema as typed. */
jsonSchemaText: string;
/** The schema's validation fragment, or null. */
jsonSchemaError: string | null;
onJsonSchemaChange: (text: string) => void;
}
/** Settings for a Code node: code, inputs, output and schema. */
export default function CodePanel({
node: selectedNode,
onUpdate: handleUpdateNodeData,
documentOptions: selectedCodeDocumentOptions,
jsonSchemaText: selectedCodeJsonSchemaText,
jsonSchemaError: selectedCodeJsonSchemaError,
onJsonSchemaChange: handleCodeJsonSchemaChange,
}: CodePanelProps) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-5">
<p className="text-muted-foreground text-sm">
{t('agents.workflow.builder.codeIntro')}
</p>
<FormField
labelSurface="background"
label={t('agents.workflow.nodes.code')}
>
<Textarea
value={selectedNode.data.config?.code ?? ''}
onChange={(e) =>
handleUpdateNodeData({
config: {
...(selectedNode.data.config || {}),
code: e.target.value,
},
})
}
className="font-mono"
rows={10}
spellCheck={false}
placeholder={t('agents.workflow.builder.examplePlaceholder', {
...NO_ESCAPE,
example: 'print("hello world")',
})}
/>
</FormField>
<NodeDocumentsControl
key={selectedNode.id}
value={selectedNode.data.config?.inputs ?? []}
onChange={(nextInputs) =>
handleUpdateNodeData({
config: {
...(selectedNode.data.config || {}),
inputs: nextInputs,
},
})
}
options={selectedCodeDocumentOptions}
label={t('agents.workflow.builder.inputFiles')}
helpText={t('agents.workflow.builder.inputFilesHint')}
/>
<FormField
labelSurface="background"
label={t('agents.workflow.builder.outputVariable')}
>
<Input
type="text"
value={selectedNode.data.config?.output_variable || ''}
onChange={(e) =>
handleUpdateNodeData({
config: {
...(selectedNode.data.config || {}),
output_variable: e.target.value,
},
})
}
placeholder={t('agents.workflow.builder.outputVariablePlaceholder')}
/>
</FormField>
<FormField
labelSurface="background"
label={t('agents.workflow.builder.timeout')}
>
<Input
type="number"
min={1}
value={selectedNode.data.config?.timeout ?? ''}
onChange={(e) => {
const raw = e.target.value;
const parsed =
raw.trim() === '' ? undefined : Number.parseInt(raw, 10);
handleUpdateNodeData({
config: {
...(selectedNode.data.config || {}),
timeout:
parsed !== undefined && Number.isFinite(parsed)
? parsed
: undefined,
},
});
}}
placeholder={t('agents.workflow.builder.optional')}
/>
</FormField>
<FormField
labelSurface="background"
label={t('agents.workflow.builder.structuredOutput')}
hint={
selectedCodeJsonSchemaText.trim() !== '' &&
!selectedCodeJsonSchemaError
? t('agents.workflow.builder.validSchema')
: undefined
}
error={
selectedCodeJsonSchemaText.trim() !== '' &&
selectedCodeJsonSchemaError
? t('agents.workflow.builder.invalidSchema', {
...NO_ESCAPE,
error: schemaErrorText(t, selectedCodeJsonSchemaError),
})
: undefined
}
>
<Textarea
value={selectedCodeJsonSchemaText}
onChange={(e) => handleCodeJsonSchemaChange(e.target.value)}
className="font-mono"
rows={6}
placeholder={`{
"type": "object",
"properties": {
"result": { "type": "string" }
},
"required": ["result"]
}`}
/>
</FormField>
</div>
);
}
@@ -0,0 +1,107 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { type Node } from 'reactflow';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
Trans: ({ i18nKey }: { i18nKey: string }) => i18nKey,
}));
import ConditionPanel from './ConditionPanel';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const node: Node = {
id: 'condition_1',
type: 'condition',
position: { x: 0, y: 0 },
data: {
title: 'Route',
config: {
mode: 'simple',
cases: [
{
name: 'People Ops',
expression: 'category.contains("people")',
sourceHandle: 'case_0',
},
],
},
},
};
describe('ConditionPanel', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
const render = () => {
const onUpdate = vi.fn();
act(() => {
root.render(
<ConditionPanel
node={node}
onUpdate={onUpdate}
onRemoveBranch={vi.fn()}
/>,
);
});
return { onUpdate };
};
it('picks the mode with a ToggleGroup in the muted track', () => {
const { onUpdate } = render();
const group = container.querySelector('[role="radiogroup"]')!;
expect(group.parentElement!.className).toContain('bg-muted');
const advanced = Array.from(group.querySelectorAll('button')).find(
(b) => b.textContent === 'agents.workflow.builder.modeAdvanced',
)!;
act(() => advanced.click());
expect(onUpdate).toHaveBeenCalledWith({
config: expect.objectContaining({ mode: 'advanced' }),
});
});
it('labels the branch name, variable, operator and value fields', () => {
render();
const labels = Array.from(
container.querySelectorAll('[data-slot="form-field-label"]'),
).map((l) => l.textContent);
expect(labels).toEqual([
'agents.workflow.builder.branchName',
'agents.workflow.builder.variable',
'agents.workflow.builder.operator',
'agents.workflow.builder.value',
]);
});
it('wires the operator label to its select, with no aria-label', () => {
render();
const trigger = container.querySelector('[role="combobox"]')!;
expect(trigger.hasAttribute('aria-label')).toBe(false);
const label = container.querySelector(`label[for="${trigger.id}"]`);
expect(label?.textContent).toBe('agents.workflow.builder.operator');
});
it('adds a condition with an outline pill button', () => {
const { onUpdate } = render();
const add = Array.from(container.querySelectorAll('button')).find((b) =>
b.textContent?.includes('agents.workflow.builder.addCondition'),
)!;
expect(add.getAttribute('data-variant')).toBe('outline');
act(() => add.click());
const cases = onUpdate.mock.calls[0][0].config.cases;
expect(cases).toHaveLength(2);
expect(cases[1].sourceHandle).toBe('case_1');
});
});
@@ -0,0 +1,291 @@
import { Plus, Trash2 } from 'lucide-react';
import { Trans, useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { FormField } from '@/components/ui/form-field';
import { IconButton } from '@/components/ui/icon-button';
import { Input } from '@/components/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { SectionHeader } from '@/components/ui/section-header';
import { Textarea } from '@/components/ui/textarea';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { ConditionCase } from '../../types/workflow';
import { buildSimpleCel, parseSimpleCel } from '../simpleCel';
import {
getNextConditionHandle,
normalizeConditionCases,
} from '../workflowHelpers';
import { type NodePanelBodyProps } from './types';
interface ConditionPanelProps extends NodePanelBodyProps {
/** Drop the edges leaving the node from a removed case's handle. */
onRemoveBranch: (sourceHandle: string) => void;
}
type ConditionMode = 'simple' | 'advanced';
/**
* Settings for an If / Else node: Simple or Advanced mode, then one row box
* per case (If, Else if, …) and Add condition.
*/
export default function ConditionPanel({
node,
onUpdate,
onRemoveBranch,
}: ConditionPanelProps) {
const { t } = useTranslation();
const config = node.data.config || {};
const mode: ConditionMode =
config.mode === 'advanced' ? 'advanced' : 'simple';
const cases: ConditionCase[] = config.cases || [];
const updateConfig = (patch: Record<string, unknown>) =>
onUpdate({ config: { ...(node.data.config || {}), ...patch } });
const updateCase = (idx: number, patch: Partial<ConditionCase>) => {
const next = [...cases];
next[idx] = { ...next[idx], ...patch };
updateConfig({ cases: next });
};
const removeCase = (idx: number) => {
const next = normalizeConditionCases([...cases]);
const removedHandle = next[idx]?.sourceHandle;
next.splice(idx, 1);
updateConfig({ cases: next });
if (removedHandle) onRemoveBranch(removedHandle);
};
const addCase = () => {
const next = normalizeConditionCases([...cases]);
next.push({
name: '',
expression: '',
sourceHandle: getNextConditionHandle(next),
});
updateConfig({ cases: next });
};
return (
<div className="flex flex-col gap-5">
<p className="text-muted-foreground text-sm">
{t('agents.workflow.builder.conditionIntro')}
</p>
{/* The track is a plain wrapper: ToggleGroup takes layout only. */}
<div className="bg-muted rounded-full p-1">
<ToggleGroup
type="single"
size="xs"
value={mode}
onValueChange={(next) => next && updateConfig({ mode: next })}
aria-label={t('agents.workflow.builder.conditionMode')}
className="flex-nowrap"
>
<ToggleGroupItem value="simple" className="flex-1">
{t('agents.workflow.builder.modeSimple')}
</ToggleGroupItem>
<ToggleGroupItem value="advanced" className="flex-1">
{t('agents.workflow.builder.modeAdvanced')}
</ToggleGroupItem>
</ToggleGroup>
</div>
{cases.map((c, idx) => {
const parsed = parseSimpleCel(c.expression);
return (
<Card
key={c.sourceHandle}
variant="subtle"
padding="sm"
className="gap-4"
>
<div className="flex items-center gap-2">
<span
aria-hidden="true"
className="bg-warning size-2 shrink-0 rounded-full"
/>
<SectionHeader
as="h4"
size="xs"
className="flex-1"
title={
idx === 0
? t('agents.workflow.nodes.if')
: t('agents.workflow.nodes.elseIf')
}
/>
{cases.length > 1 && (
<IconButton
variant="ghost-destructive"
size="icon-xs"
label={t('agents.workflow.removeCondition', {
index: idx + 1,
})}
icon={Trash2}
onClick={() => removeCase(idx)}
/>
)}
</div>
<FormField
label={t('agents.workflow.builder.branchName')}
labelSurface="background"
>
<Input
type="text"
value={c.name || ''}
onChange={(e) => updateCase(idx, { name: e.target.value })}
placeholder={t('agents.workflow.builder.caseNamePlaceholder')}
/>
</FormField>
{mode === 'simple' ? (
<>
<FormField
label={t('agents.workflow.builder.variable')}
labelSurface="background"
>
<Input
type="text"
className="font-mono"
value={parsed.variable}
onChange={(e) =>
updateCase(idx, {
expression: buildSimpleCel(
e.target.value,
parsed.operator,
parsed.value,
),
})
}
placeholder={t(
'agents.workflow.builder.variablePlaceholder',
)}
/>
</FormField>
<div className="flex gap-2">
<FormField
label={t('agents.workflow.builder.operator')}
labelSurface="background"
className="w-32 shrink-0"
>
<Select
value={parsed.operator}
onValueChange={(op) =>
updateCase(idx, {
expression: buildSimpleCel(
parsed.variable,
op,
parsed.value,
),
})
}
>
<SelectTrigger size="field" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="==">=</SelectItem>
<SelectItem value="!=">!=</SelectItem>
<SelectItem value=">">&gt;</SelectItem>
<SelectItem value="<">&lt;</SelectItem>
<SelectItem value=">=">&gt;=</SelectItem>
<SelectItem value="<=">&lt;=</SelectItem>
<SelectItem value="contains">
{t('agents.workflow.builder.opContains')}
</SelectItem>
<SelectItem value="startsWith">
{t('agents.workflow.builder.opStartsWith')}
</SelectItem>
</SelectContent>
</Select>
</FormField>
<FormField
label={t('agents.workflow.builder.value')}
labelSurface="background"
className="min-w-0 flex-1"
>
<Input
type="text"
value={parsed.value}
onChange={(e) =>
updateCase(idx, {
expression: buildSimpleCel(
parsed.variable,
parsed.operator,
e.target.value,
),
})
}
placeholder={t(
'agents.workflow.builder.valuePlaceholder',
)}
/>
</FormField>
</div>
</>
) : (
<FormField
label={t('agents.workflow.conditionRow', { index: idx + 1 })}
labelSurface="background"
hint={
<>
<Trans
i18nKey="agents.workflow.builder.celHint"
components={{ code: <code /> }}
values={{ braced: '{{query}}' }}
/>{' '}
<Button
variant="link"
size="inline"
asChild
// eslint-disable-next-line shadcn/no-restyle -- a link in a 12px hint keeps the sentence's size and weight
className="text-xs font-normal"
>
<a
href="https://cel.dev/"
target="_blank"
rel="noreferrer"
>
{t('agents.workflow.builder.learnMore')}
</a>
</Button>
</>
}
>
<Textarea
value={c.expression}
onChange={(e) =>
updateCase(idx, { expression: e.target.value })
}
rows={2}
placeholder={t(
'agents.workflow.builder.conditionPlaceholder',
)}
/>
</FormField>
)}
</Card>
);
})}
<Button
type="button"
variant="outline"
size="sm"
shape="pill"
onClick={addCase}
className="self-start"
>
<Plus />
{t('agents.workflow.builder.addCondition')}
</Button>
</div>
);
}
@@ -0,0 +1,123 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { type Node } from 'reactflow';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
import NodePanel from './NodePanel';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const agentNode: Node = {
id: 'classify',
type: 'agent',
position: { x: 0, y: 0 },
data: { title: 'Classify request' },
};
const startNode: Node = {
id: 'start',
type: 'start',
position: { x: 0, y: 0 },
data: { label: 'Start' },
};
describe('NodePanel', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
document.body.innerHTML = '';
});
const render = (node: Node) => {
const handlers = {
onClose: vi.fn(),
onDuplicate: vi.fn(),
onDelete: vi.fn(),
onUpdate: vi.fn(),
};
act(() => {
root.render(
<NodePanel node={node} {...handlers}>
<div data-testid="body">body</div>
</NodePanel>,
);
});
return handlers;
};
const menuTrigger = () =>
container.querySelector(
'[aria-label="agents.workflow.builder.nodeActions"]',
) as HTMLButtonElement | null;
it('shows the icon, own title, type badge and id', () => {
render(agentNode);
const header = container.querySelector('header')!;
expect(header.textContent).toContain('Classify request');
const badge = header.querySelector('[data-slot="badge"]')!;
expect(badge.textContent).toBe('agents.workflow.builder.aiAgent');
expect(badge.getAttribute('data-variant')).toBe('neutral');
expect(header.textContent).toContain('classify');
expect(header.querySelector('.bg-secondary')).not.toBeNull();
});
it('offers Duplicate and Delete node in the node menu', () => {
const { onDuplicate, onDelete } = render(agentNode);
const trigger = menuTrigger();
expect(trigger).not.toBeNull();
act(() => {
trigger!.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }),
);
});
const items = Array.from(
document.querySelectorAll('[role="menuitem"]'),
) as HTMLElement[];
expect(items.map((i) => i.textContent)).toEqual([
'agents.workflow.builder.duplicateNode',
'agents.workflow.builder.deleteNode',
]);
expect(items[1].getAttribute('data-variant')).toBe('destructive');
act(() => items[0].click());
expect(onDuplicate).toHaveBeenCalled();
expect(onDelete).not.toHaveBeenCalled();
});
it('has no node menu and no title field for the start node', () => {
render(startNode);
expect(menuTrigger()).toBeNull();
expect(container.querySelector('input')).toBeNull();
expect(container.textContent).not.toContain('cannotDeleteStart');
});
it('closes from the header', () => {
const { onClose } = render(agentNode);
const close = container.querySelector(
'[aria-label="agents.close"]',
) as HTMLButtonElement;
act(() => close.click());
expect(onClose).toHaveBeenCalled();
});
it('edits the title on the background surface and renders the body', () => {
const { onUpdate } = render(agentNode);
const label = container.querySelector(
'[data-slot="form-field-label"]',
) as HTMLElement;
expect(label.className).toContain('bg-background');
expect(container.querySelector('[data-testid="body"]')).not.toBeNull();
expect(onUpdate).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,151 @@
import { Copy, Trash2, X } from 'lucide-react';
import { type ReactNode } from 'react';
import { useTranslation } from 'react-i18next';
import { type Node } from 'reactflow';
import { Badge } from '@/components/ui/badge';
import { ActionMenu } from '@/components/ui/dropdown-menu';
import { FormField } from '@/components/ui/form-field';
import { IconButton } from '@/components/ui/icon-button';
import { Input } from '@/components/ui/input';
import { cn } from '@/lib/utils';
import CopyButton from '../../../components/CopyButton';
import { NODE_META, nodeToneClass, type WorkflowNodeType } from '../nodeTones';
interface NodePanelProps {
/** The node being edited. */
node: Node;
onClose: () => void;
onDuplicate: () => void;
onDelete: () => void;
/** Merge fields into the node's data (used by the title field). */
onUpdate: (data: Record<string, unknown>) => void;
/** The type's own settings (AgentPanel, ConditionPanel, …). */
children?: ReactNode;
}
// Start and End have fixed names and no settings of their own.
const FIXED_TYPES = new Set(['start', 'end']);
const FIXED_HINT_KEYS: Record<string, string> = {
start: 'agents.workflow.nodes.startHint',
end: 'agents.workflow.nodes.endHint',
};
/**
* The node settings column docked at the canvas's right edge: a header with
* the type's icon, the node's title, its type and id, a ⋯ menu (Duplicate,
* Delete node; none for Start) and close, then the one scrolling body.
*/
export default function NodePanel({
node,
onClose,
onDuplicate,
onDelete,
onUpdate,
children,
}: NodePanelProps) {
const { t } = useTranslation();
const meta = NODE_META[node.type as WorkflowNodeType];
const Icon = meta?.icon;
const typeLabel = meta ? t(meta.labelKey) : (node.type ?? '');
const fixed = FIXED_TYPES.has(node.type ?? '');
const title = fixed
? typeLabel
: node.data.title || node.data.label || typeLabel;
return (
<aside className="bg-background border-border flex w-96 shrink-0 flex-col border-l">
<header className="border-border flex items-start gap-3 border-b px-4 py-3">
<span
className={cn(
'flex size-8 shrink-0 items-center justify-center rounded-md',
nodeToneClass(node.type),
)}
>
{Icon && <Icon className="size-4" aria-hidden="true" />}
</span>
<div className="flex min-w-0 flex-1 flex-col">
<div className="flex min-w-0 items-center gap-2">
<h2
className="text-foreground truncate text-sm font-semibold"
title={title}
>
{title}
</h2>
{title !== typeLabel && (
<Badge variant="neutral">{typeLabel}</Badge>
)}
</div>
<div className="flex min-w-0 items-center gap-1">
<span
className="text-muted-foreground truncate font-mono text-xs"
title={node.id}
>
{node.id}
</span>
<CopyButton
textToCopy={node.id}
size="xs"
copyLabel={t('agents.workflow.builder.copyNodeId')}
side="bottom"
/>
</div>
</div>
{node.type !== 'start' && (
<ActionMenu
size="toolbar"
triggerLabel={t('agents.workflow.builder.nodeActions')}
options={[
{
label: t('agents.workflow.builder.duplicateNode'),
icon: Copy,
onClick: onDuplicate,
},
{
label: t('agents.workflow.builder.deleteNode'),
icon: Trash2,
variant: 'destructive',
onClick: onDelete,
},
]}
/>
)}
<IconButton
variant="ghost-muted"
size="icon-sm"
side="bottom"
onClick={onClose}
label={t('agents.close')}
icon={X}
/>
</header>
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="flex flex-col gap-6 p-4">
{fixed ? (
<p className="text-muted-foreground text-sm">
{t(FIXED_HINT_KEYS[node.type ?? ''])}
</p>
) : (
<FormField
label={t('agents.workflow.builder.title')}
labelSurface="background"
>
<Input
type="text"
value={node.data.title || node.data.label || ''}
onChange={(e) =>
onUpdate({ title: e.target.value, label: e.target.value })
}
placeholder={t('agents.workflow.builder.titlePlaceholder')}
/>
</FormField>
)}
{children}
</div>
</div>
</aside>
);
}
@@ -0,0 +1,31 @@
import { useTranslation } from 'react-i18next';
import { FormField } from '@/components/ui/form-field';
import { Textarea } from '@/components/ui/textarea';
import { type NodePanelBodyProps } from './types';
/** Settings for a note node: its text. */
export default function NotePanel({
node: selectedNode,
onUpdate: handleUpdateNodeData,
}: NodePanelBodyProps) {
const { t } = useTranslation();
return (
<FormField
labelSurface="background"
label={t('agents.workflow.builder.noteContent')}
>
<Textarea
value={selectedNode.data.content || ''}
onChange={(e) =>
handleUpdateNodeData({
content: e.target.value,
})
}
rows={4}
placeholder={t('agents.workflow.builder.noteContentPlaceholder')}
/>
</FormField>
);
}
@@ -0,0 +1,92 @@
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { type Node } from 'reactflow';
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string, opts?: { index?: number }) =>
opts?.index !== undefined ? `${key}:${opts.index}` : key,
}),
Trans: ({ i18nKey }: { i18nKey: string }) => i18nKey,
}));
import StatePanel from './StatePanel';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const node: Node = {
id: 'state_1',
type: 'state',
position: { x: 0, y: 0 },
data: {
title: 'Set priority',
config: {
operations: [
{ expression: '"P1"', target_variable: 'priority' },
{ expression: '"4h"', target_variable: 'sla' },
],
},
},
};
describe('StatePanel', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
const render = () => {
const onUpdate = vi.fn();
act(() => {
root.render(<StatePanel node={node} onUpdate={onUpdate} />);
});
return { onUpdate };
};
it('explains CEL once, in the intro', () => {
render();
const matches = container.textContent!.match(
/agents\.workflow\.builder\.celHint/g,
);
expect(matches).toHaveLength(1);
expect(
container.querySelectorAll('a[href="https://cel.dev/"]'),
).toHaveLength(1);
});
it('numbers each assignment and puts the variable before the value', () => {
render();
expect(container.textContent).toContain(
'agents.workflow.builder.assignment:1',
);
expect(container.textContent).toContain(
'agents.workflow.builder.assignment:2',
);
const labels = Array.from(
container.querySelectorAll('[data-slot="form-field-label"]'),
).map((l) => l.textContent);
expect(labels.slice(0, 2)).toEqual([
'agents.workflow.builder.variable',
'agents.workflow.builder.valueCel',
]);
});
it('adds an assignment with an outline pill button', () => {
const { onUpdate } = render();
const add = Array.from(container.querySelectorAll('button')).find((b) =>
b.textContent?.includes('agents.workflow.builder.addAssignment'),
)!;
expect(add.getAttribute('data-variant')).toBe('outline');
act(() => add.click());
expect(onUpdate.mock.calls[0][0].config.operations).toHaveLength(3);
});
});
@@ -0,0 +1,132 @@
import { Plus, Trash2 } from 'lucide-react';
import { Trans, useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { FormField } from '@/components/ui/form-field';
import { IconButton } from '@/components/ui/icon-button';
import { Input } from '@/components/ui/input';
import { SectionHeader } from '@/components/ui/section-header';
import { Textarea } from '@/components/ui/textarea';
import { StateOperationConfig } from '../../types/workflow';
import { NO_ESCAPE } from '../workflowHelpers';
import { type NodePanelBodyProps } from './types';
/**
* Settings for a Set State node: an intro that explains CEL once, then one
* numbered row box per assignment (variable, then its CEL value).
*/
export default function StatePanel({ node, onUpdate }: NodePanelBodyProps) {
const { t } = useTranslation();
const operations: StateOperationConfig[] = node.data.config?.operations || [];
const setOperations = (next: StateOperationConfig[]) =>
onUpdate({ config: { ...(node.data.config || {}), operations: next } });
const updateOperation = (
idx: number,
patch: Partial<StateOperationConfig>,
) => {
const next = [...operations];
next[idx] = { ...next[idx], ...patch };
setOperations(next);
};
return (
<div className="flex flex-col gap-5">
<p className="text-muted-foreground text-sm">
{t('agents.workflow.builder.stateIntro')}{' '}
<Trans
i18nKey="agents.workflow.builder.celHint"
components={{ code: <code /> }}
values={{ braced: '{{query}}' }}
/>{' '}
<Button variant="link" size="inline" asChild>
<a href="https://cel.dev/" target="_blank" rel="noreferrer">
{t('agents.workflow.builder.learnMore')}
</a>
</Button>
</p>
{operations.map((op, idx) => (
<Card key={idx} variant="subtle" padding="sm" className="gap-4">
<div className="flex items-center gap-2">
<SectionHeader
as="h4"
size="xs"
className="flex-1"
title={t('agents.workflow.builder.assignment', {
index: idx + 1,
})}
/>
{operations.length > 1 && (
<IconButton
variant="ghost-destructive"
size="icon-xs"
label={t('agents.workflow.removeAssignment', {
index: idx + 1,
})}
icon={Trash2}
onClick={() =>
setOperations(operations.filter((_, i) => i !== idx))
}
/>
)}
</div>
<FormField
label={t('agents.workflow.builder.variable')}
labelSurface="background"
>
<Input
type="text"
className="font-mono"
value={op.target_variable}
onChange={(e) =>
updateOperation(idx, { target_variable: e.target.value })
}
placeholder={t('agents.workflow.builder.examplePlaceholder', {
...NO_ESCAPE,
example: 'variable_name',
})}
/>
</FormField>
<FormField
label={t('agents.workflow.builder.valueCel')}
labelSurface="background"
>
<Textarea
className="font-mono"
value={op.expression}
onChange={(e) =>
updateOperation(idx, { expression: e.target.value })
}
rows={2}
placeholder={t('agents.workflow.builder.examplePlaceholder', {
...NO_ESCAPE,
example: 'query',
})}
/>
</FormField>
</Card>
))}
<Button
type="button"
variant="outline"
size="sm"
shape="pill"
onClick={() =>
setOperations([
...operations,
{ expression: '', target_variable: '' },
])
}
className="self-start"
>
<Plus />
{t('agents.workflow.builder.addAssignment')}
</Button>
</div>
);
}
@@ -0,0 +1,12 @@
import { type Node } from 'reactflow';
/** Props every node settings panel body takes. */
export interface NodePanelBodyProps {
/** The node being edited. */
node: Node;
/** Merge fields into the node's data (a snapshot first unless disabled). */
onUpdate: (
data: Record<string, unknown>,
options?: { snapshot?: boolean },
) => void;
}
@@ -0,0 +1,39 @@
import { describe, expect, it } from 'vitest';
import { findFreePosition } from './workflowHelpers';
const box = (id: string, x: number, y: number, width = 200, height = 60) => ({
id,
position: { x, y },
width,
height,
});
describe('findFreePosition', () => {
it('keeps the wanted spot when nothing is there', () => {
expect(findFreePosition([box('a', 0, 0)], { x: 400, y: 0 })).toEqual({
x: 400,
y: 0,
});
});
it('moves down past a node that covers the spot', () => {
const spot = findFreePosition([box('a', 380, 0)], { x: 400, y: 0 });
expect(spot.x).toBe(400);
expect(spot.y).toBeGreaterThanOrEqual(60 + 24);
});
it('keeps moving down past a column of nodes', () => {
const nodes = [box('a', 400, 0), box('b', 400, 84), box('c', 400, 168)];
const spot = findFreePosition(nodes, { x: 400, y: 0 });
expect(spot.y).toBeGreaterThanOrEqual(168 + 60 + 24);
});
it('uses a default size for nodes React Flow has not measured yet', () => {
const spot = findFreePosition([{ id: 'a', position: { x: 400, y: 0 } }], {
x: 450,
y: 10,
});
expect(spot.y).toBeGreaterThan(10);
});
});
@@ -0,0 +1,204 @@
import { type TFunction } from 'i18next';
import { ConditionCase } from '../types/workflow';
import { FilePassing } from './documentConfig';
// Names and handles are the user's own text: React escapes on render, so
// i18next must not escape them first.
export const NO_ESCAPE = { interpolation: { escapeValue: false } } as const;
export interface AgentNodeConfig {
agent_type: 'classic' | 'research';
llm_name?: string;
model_id?: string;
system_prompt: string;
prompt_template: string;
output_variable?: string;
stream_to_user: boolean;
sources: string[];
tools: string[];
chunks?: string;
retriever?: string;
json_schema?: Record<string, unknown>;
input_documents?: string[];
file_passing?: FilePassing;
}
export interface UserTool {
id: string;
name: string;
displayName: string;
customName?: string;
// Workflow-only builtins (e.g. read_document) are kept here; the classic
// agent picker filters them out.
workflow_only?: boolean;
}
/**
* Check an agent node's structured-output schema.
*
* Args:
* schema: The parsed schema, or undefined when none is set.
*
* Returns:
* A short English error fragment, or null when the schema is usable.
*/
export function validateJsonSchemaConfig(schema: unknown): string | null {
if (schema === undefined || schema === null) return null;
if (typeof schema !== 'object' || Array.isArray(schema)) {
return 'must be a valid JSON object';
}
const schemaObject = schema as Record<string, unknown>;
if (!('schema' in schemaObject) && !('type' in schemaObject)) {
return 'must include either a "type" or "schema" field';
}
return null;
}
// The schema validators return short English fragments (tests and the
// validation list key off them); these map each to its locale key.
const SCHEMA_ERROR_KEYS: Record<string, string> = {
'must be a valid JSON object': 'agents.workflow.schema.notObject',
'must include either a "type" or "schema" field':
'agents.workflow.schema.missingType',
'must be valid JSON': 'agents.workflow.schema.invalidJson',
};
/**
* Translate a JSON schema validation fragment.
*
* Args:
* t: The i18next translate function.
* fragment: The fragment a schema validator returned.
*
* Returns:
* The translated fragment, or the fragment itself when it is unknown.
*/
export function schemaErrorText(t: TFunction, fragment: string): string {
const key = SCHEMA_ERROR_KEYS[fragment];
return key ? t(key) : fragment;
}
/**
* Give every condition case a unique `case_N` source handle.
*
* Args:
* cases: The cases as stored.
*
* Returns:
* The cases, with missing or duplicate handles replaced.
*/
export function normalizeConditionCases(
cases: ConditionCase[],
): ConditionCase[] {
const usedHandles = new Set<string>();
let nextIndex = 0;
return cases.map((conditionCase) => {
const candidate = (conditionCase.sourceHandle || '').trim();
if (candidate && !usedHandles.has(candidate)) {
usedHandles.add(candidate);
const match = candidate.match(/^case_(\d+)$/);
if (match) {
nextIndex = Math.max(nextIndex, Number(match[1]) + 1);
}
return conditionCase;
}
while (usedHandles.has(`case_${nextIndex}`)) {
nextIndex += 1;
}
const generatedHandle = `case_${nextIndex}`;
usedHandles.add(generatedHandle);
nextIndex += 1;
return {
...conditionCase,
sourceHandle: generatedHandle,
};
});
}
/**
* The next free `case_N` handle for a new condition case.
*
* Args:
* cases: The existing cases.
*
* Returns:
* An unused handle.
*/
export function getNextConditionHandle(cases: ConditionCase[]): string {
const usedHandles = new Set(
cases.map((conditionCase) => conditionCase.sourceHandle).filter(Boolean),
);
const usedIndices = Array.from(usedHandles)
.map((handle) => handle.match(/^case_(\d+)$/))
.filter((match): match is RegExpMatchArray => Boolean(match))
.map((match) => Number(match[1]));
let nextIndex = usedIndices.length > 0 ? Math.max(...usedIndices) + 1 : 0;
while (usedHandles.has(`case_${nextIndex}`)) {
nextIndex += 1;
}
return `case_${nextIndex}`;
}
interface PlacedNode {
id: string;
position: { x: number; y: number };
width?: number | null;
height?: number | null;
}
// Nodes React Flow hasn't measured yet count as a typical node.
const DEFAULT_NODE_WIDTH = 200;
const DEFAULT_NODE_HEIGHT = 64;
const NODE_GAP = 24;
const MAX_PLACEMENT_STEPS = 50;
/**
* Find a spot for a new node that doesn't cover an existing one.
*
* Starts at `wanted` (the new node's top-left corner) and steps straight down
* until the new node's box, with a gap around it, clears every other node.
*
* Args:
* nodes: The nodes already on the canvas.
* wanted: Where the new node would ideally go.
* size: The new node's expected size.
*
* Returns:
* The first free top-left position at or below `wanted`.
*/
export function findFreePosition(
nodes: PlacedNode[],
wanted: { x: number; y: number },
size: { width: number; height: number } = {
width: DEFAULT_NODE_WIDTH,
height: DEFAULT_NODE_HEIGHT,
},
): { x: number; y: number } {
const overlapping = (y: number) =>
nodes.find((node) => {
const width = node.width ?? DEFAULT_NODE_WIDTH;
const height = node.height ?? DEFAULT_NODE_HEIGHT;
return (
wanted.x < node.position.x + width + NODE_GAP &&
wanted.x + size.width + NODE_GAP > node.position.x &&
y < node.position.y + height + NODE_GAP &&
y + size.height + NODE_GAP > node.position.y
);
});
let y = wanted.y;
for (let step = 0; step < MAX_PLACEMENT_STEPS; step += 1) {
const blocker = overlapping(y);
if (!blocker) break;
y = blocker.position.y + (blocker.height ?? DEFAULT_NODE_HEIGHT) + NODE_GAP;
}
return { x: wanted.x, y };
}
+15
View File
@@ -68,6 +68,19 @@ const endpoints = {
ENABLE_GRAPHRAG: (id: string) => `/api/sources/${id}/graphrag/enable`,
SOURCE_GRAPH: (id: string, limit?: number) =>
`/api/sources/${id}/graph${limit ? `?limit=${limit}` : ''}`,
SOURCE_GRAPH_NODES: (
id: string,
params: { q?: string; type?: string; page?: number; perPage?: number },
) => {
const search = new URLSearchParams();
if (params.q) search.set('q', params.q);
// An empty type filters to untyped nodes, so it is sent only when set.
if (params.type !== undefined) search.set('type', params.type);
if (params.page) search.set('page', String(params.page));
if (params.perPage) search.set('per_page', String(params.perPage));
const qs = search.toString();
return `/api/sources/${id}/graph/nodes${qs ? `?${qs}` : ''}`;
},
SOURCE_GRAPH_NODE: (id: string, nodeId: string) =>
`/api/sources/${id}/graph/node/${encodeURIComponent(nodeId)}`,
TASK_STATUS: (taskId: string) => `/api/task_status?task_id=${taskId}`,
@@ -141,6 +154,8 @@ const endpoints = {
CUSTOM_MODEL_TEST: (id: string) => `/api/user/models/${id}/test`,
CUSTOM_MODEL_TEST_PAYLOAD: '/api/user/models/test',
AGENT_SCHEDULES: (agentId: string) => `/api/agents/${agentId}/schedules`,
AGENT_SCHEDULE_STATS: (agentId: string, days?: number) =>
`/api/agents/${agentId}/schedules/stats?days=${days ?? 30}`,
SCHEDULE: (id: string) => `/api/schedules/${id}`,
SCHEDULE_RUN_NOW: (id: string) => `/api/schedules/${id}/run`,
SCHEDULE_RUNS: (id: string, limit?: number, offset?: number) =>
@@ -0,0 +1,32 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import apiClient from '../client';
import schedulesService from './schedulesService';
afterEach(() => vi.restoreAllMocks());
const response = (body: unknown, status = 200) =>
({
ok: status >= 200 && status < 300,
status,
json: async () => body,
}) as unknown as Response;
describe('schedulesService.statsForAgent', () => {
it('returns the stats body on success', async () => {
const body = { days: 30, runs: 4, failed: 1, tokens: 120 };
vi.spyOn(apiClient, 'get').mockResolvedValue(response(body));
await expect(schedulesService.statsForAgent('a1', 't')).resolves.toEqual(
body,
);
});
it('rejects on an error response instead of returning its body as stats', async () => {
vi.spyOn(apiClient, 'get').mockResolvedValue(
response({ success: false, message: 'Agent not found' }, 404),
);
await expect(schedulesService.statsForAgent('a1', 't')).rejects.toThrow();
});
});
@@ -6,6 +6,7 @@ import type {
ScheduleResponse,
ScheduleRunListResponse,
ScheduleRunResponse,
ScheduleStats,
ScheduleUpdatePayload,
} from '../../agents/types/schedule';
@@ -27,6 +28,23 @@ const schedulesService = {
return (await json(r)) as ScheduleListResponse;
},
statsForAgent: async (
agentId: string,
token: string | null,
days = 30,
): Promise<ScheduleStats> => {
const r = await apiClient.get(
endpoints.USER.AGENT_SCHEDULE_STATS(agentId, days),
token,
);
// The error body ({success: false, message}) is not stats: reject so the
// caller shows "—" instead of empty totals.
if (!(r as Response).ok) {
throw new Error(`Schedule stats failed: ${(r as Response).status}`);
}
return (await json(r)) as ScheduleStats;
},
create: async (
agentId: string,
payload: ScheduleCreatePayload,
+9
View File
@@ -153,6 +153,15 @@ const userService = {
limit?: number,
): Promise<Response> =>
throttledApiClient.get(endpoints.USER.SOURCE_GRAPH(sourceId, limit), token),
getSourceGraphNodes: (
sourceId: string,
params: { q?: string; type?: string; page?: number; perPage?: number },
token: string | null,
): Promise<Response> =>
throttledApiClient.get(
endpoints.USER.SOURCE_GRAPH_NODES(sourceId, params),
token,
),
getSourceGraphNode: (
sourceId: string,
nodeId: string,
@@ -43,6 +43,31 @@ describe('ArtifactSidebar', () => {
container.remove();
});
// Every Sheet closes with its built-in X (ghost-muted icon-sm, top-2
// right-2); the header only names the artifact.
it('closes the phone sheet with the built-in close button', async () => {
documentMock.mockResolvedValue({ ok: false, status: 500 });
legacyMock.mockRejectedValue(new Error('offline'));
await act(async () => {
root.render(
<ArtifactSidebar
isOpen
onClose={vi.fn()}
artifactId="a1"
conversationId="c1"
variant="overlay"
/>,
);
});
const sheet = document.querySelector('[data-slot="sheet-content"]')!;
// One close: the Sheet's built-in X, named in the UI language.
const closes = sheet.querySelectorAll('button[aria-label="agents.close"]');
expect(closes).toHaveLength(1);
expect(closes[0].getAttribute('data-variant')).toBe('ghost-muted');
expect(sheet.querySelector('[aria-label="Close"]')).toBeNull();
expect(sheet.querySelector('[data-slot="sheet-title"]')).not.toBeNull();
});
it('shows a failed load as an alert with Retry that refetches', async () => {
documentMock.mockResolvedValue({ ok: false, status: 500 });
legacyMock.mockRejectedValue(new Error('offline'));
+12 -25
View File
@@ -11,7 +11,7 @@ import {
} from 'react-syntax-highlighter/dist/cjs/styles/prism';
import remarkGfm from 'remark-gfm';
import { markdownHeadings } from '@/lib/markdown';
import { markdownHeadings, markdownTables } from '@/lib/markdown';
import { cn } from '@/lib/utils';
import userService from '../api/services/userService';
@@ -26,7 +26,7 @@ import { Badge } from './ui/badge';
import { Button } from './ui/button';
import { Card } from './ui/card';
import { IconButton } from './ui/icon-button';
import { Sheet, SheetContent } from './ui/sheet';
import { Sheet, SheetContent, SheetTitle } from './ui/sheet';
type TodoItem = {
todo_id: number;
@@ -197,6 +197,7 @@ function NoteView({ data }: { data: NoteArtifactData }) {
remarkPlugins={[remarkGfm]}
components={{
...markdownHeadings,
...markdownTables,
code(props) {
const {
children,
@@ -528,10 +529,10 @@ export default function ArtifactSidebar({
<div className="h-14 shrink-0" />
{/* Artifact panel */}
<div className="border-border flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border bg-transparent">
<div className="flex w-full items-center justify-between px-4 py-2">
<span className="text-muted-foreground text-sm font-medium">
<div className="flex w-full items-center justify-between gap-2 px-4 py-2">
<h2 className="text-foreground min-w-0 text-sm leading-tight font-semibold wrap-anywhere">
{title}
</span>
</h2>
<IconButton
label={t('agents.close')}
side="bottom"
@@ -556,27 +557,13 @@ export default function ArtifactSidebar({
if (!open) onClose();
}}
>
<SheetContent
side="right"
showCloseButton={false}
title={title || t('components.artifact.preview')}
className="h-full w-80 p-0 sm:w-96 sm:max-w-none"
>
<SheetContent side="right" closeLabel={t('agents.close')} className="p-0">
<div className="flex min-h-0 flex-1 flex-col">
<div className="border-border flex w-full items-center justify-between border-b px-4 py-3">
<span className="text-muted-foreground text-sm font-medium">
{title}
</span>
<IconButton
label={t('agents.close')}
side="bottom"
variant="ghost"
size="icon"
shape="pill"
onClick={onClose}
>
<X aria-hidden="true" className="size-4" />
</IconButton>
{/* pr-12 keeps the title clear of the built-in close X. */}
<div className="border-border flex w-full items-center border-b px-4 py-3 pr-12">
<SheetTitle className="min-w-0 text-sm font-semibold wrap-anywhere">
{title || t('components.artifact.preview')}
</SheetTitle>
</div>
<div className="flex-1 overflow-hidden p-4">{renderContent()}</div>
</div>
+780 -97
View File
@@ -1,47 +1,87 @@
import { act, useState } from 'react';
import { createRoot, type Root } from 'react-dom/client';
const { dispatch, service, pagerProps } = vi.hoisted(() => ({
pagerProps: {
current: null as null | { onPageSizeChange?: (size: number) => void },
},
dispatch: vi.fn(),
service: {
getDocumentChunks: vi.fn(),
addChunk: vi.fn(),
updateChunk: vi.fn(),
deleteChunk: vi.fn(),
},
}));
// Keys come back as-is; interpolation values are appended so the tests can
// read them ("key {"n":1}").
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
useTranslation: () => ({
t: (key: string, opts?: Record<string, unknown>) =>
opts ? `${key} ${JSON.stringify(opts)}` : key,
}),
}));
vi.mock('react-redux', () => ({
useSelector: () => null,
useDispatch: () => dispatch,
}));
vi.mock('../hooks', () => ({
useDarkTheme: () => [false],
useDebouncedValue: (value: unknown) => value,
useLoaderState: (initial: boolean) => useState(initial),
useMediaQuery: () => ({ isMobile: false }),
useMediaQuery: () => ({ isMobile: false, isDesktop: true }),
useOutsideAlerter: () => undefined,
}));
vi.mock('../api/services/userService', () => ({
default: {
getDocumentChunks: vi.fn(async () => ({
ok: true,
json: async () => ({
page: 1,
per_page: 5,
total: 1,
chunks: [{ doc_id: 'c1', text: 'Late pickup clause', metadata: {} }],
}),
})),
},
}));
vi.mock('../api/services/userService', () => ({ default: service }));
// The real pager, with its props kept so a test can pick a page size without
// driving the Select popover.
vi.mock('./ui/pagination', async (importOriginal) => {
const mod = await importOriginal<typeof import('./ui/pagination')>();
return {
...mod,
Pagination: (props: React.ComponentProps<typeof mod.Pagination>) => {
pagerProps.current = props;
return mod.Pagination(props);
},
};
});
import Chunks from './Chunks';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
const setInputValue = (input: HTMLInputElement, value: string) => {
const setter = Object.getOwnPropertyDescriptor(
HTMLInputElement.prototype,
'value',
)!.set!;
setter.call(input, value);
input.dispatchEvent(new Event('input', { bubbles: true }));
const chunksResponse = (overrides: Record<string, unknown> = {}) => ({
ok: true,
json: async () => ({
page: 1,
per_page: 12,
total: 1,
chunks: [
{
doc_id: 'c1',
text: '## Late pickup clause',
metadata: { token_count: 42 },
},
],
...overrides,
}),
});
const setFieldValue = (
field: HTMLInputElement | HTMLTextAreaElement,
value: string,
) => {
const proto =
field instanceof HTMLTextAreaElement
? HTMLTextAreaElement.prototype
: HTMLInputElement.prototype;
Object.getOwnPropertyDescriptor(proto, 'value')!.set!.call(field, value);
field.dispatchEvent(new Event('input', { bubbles: true }));
};
describe('Chunks', () => {
@@ -49,6 +89,12 @@ describe('Chunks', () => {
let root: Root;
beforeEach(() => {
dispatch.mockReset();
service.getDocumentChunks.mockReset();
service.getDocumentChunks.mockImplementation(async () => chunksResponse());
service.addChunk.mockReset();
service.updateChunk.mockReset();
service.deleteChunk.mockReset();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
@@ -57,104 +103,741 @@ describe('Chunks', () => {
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
document.body.innerHTML = '';
});
const render = async (onFileSelect = vi.fn()) => {
const render = async (
props: Partial<React.ComponentProps<typeof Chunks>> = {},
) => {
await act(async () => {
root.render(
<Chunks
documentId="doc"
documentName="Contracts"
handleGoBack={vi.fn()}
onFileSearch={() => [
{ name: 'msa.pdf', path: 'contracts/msa.pdf', isFile: true },
{ name: 'sla.docx', path: 'contracts/sla.docx', isFile: true },
]}
onFileSelect={onFileSelect}
/>,
);
});
return onFileSelect;
};
it('picks a file search result with the keyboard', async () => {
const onFileSelect = await render();
const input = container.querySelector<HTMLInputElement>(
'[data-slot="command-input"]',
)!;
expect(input).not.toBeNull();
await act(async () => setInputValue(input, 'contracts'));
const items = container.querySelectorAll('[data-slot="command-item"]');
expect(items).toHaveLength(2);
await act(async () => {
input.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }),
);
});
await act(async () => {
input.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }),
);
});
expect(onFileSelect).toHaveBeenCalledWith('contracts/sla.docx');
});
it('renders each chunk tile as a button', async () => {
await render();
const tile = container.querySelector<HTMLButtonElement>(
'button[data-slot="card"]',
);
expect(tile).not.toBeNull();
expect(tile!.textContent).toContain('Late pickup clause');
expect(tile!.className).not.toContain('hover:scale-105');
});
const renderPath = async (props: {
onPathSelect?: (depth: number) => void;
handleGoBack?: () => void;
}) => {
await act(async () => {
root.render(
<Chunks
documentId="doc"
documentName="Contracts"
handleGoBack={props.handleGoBack ?? vi.fn()}
path="legal/2024/msa.pdf"
displayPath="legal/2024/msa.pdf"
onPathSelect={props.onPathSelect}
{...props}
/>,
);
});
};
const crumbButton = (label: string) =>
const tile = () =>
container.querySelector<HTMLButtonElement>('button[data-slot="card"]');
const buttonByText = (text: string) =>
Array.from(
document.body.querySelectorAll<HTMLButtonElement>('button'),
).find((el) => el.textContent?.trim() === text);
const crumbs = () =>
Array.from(container.querySelectorAll('[data-slot="breadcrumb-item"]')).map(
(el) => el.textContent,
);
const clickCrumb = async (label: string) => {
const link = Array.from(
container.querySelectorAll<HTMLButtonElement>(
'[data-slot="breadcrumb-link"]',
),
).find((el) => el.textContent === label);
if (!link) throw new Error(`no crumb link ${label}`);
await act(async () => link.click());
};
it('opens the root and parent folders from the path crumbs', async () => {
const onPathSelect = vi.fn();
await renderPath({ onPathSelect });
const buttonByLabel = (label: string) =>
document.body.querySelector<HTMLButtonElement>(
`button[aria-label="${label}"]`,
);
await act(async () => crumbButton('Contracts')!.click());
expect(onPathSelect).toHaveBeenLastCalledWith(0);
await act(async () => crumbButton('2024')!.click());
expect(onPathSelect).toHaveBeenLastCalledWith(2);
// The file is the current crumb, not a link.
expect(crumbButton('msa.pdf')).toBeUndefined();
it('has no file search column', async () => {
await render();
expect(container.querySelector('[data-slot="command-input"]')).toBeNull();
expect(container.innerHTML).not.toContain('198px');
});
it('without onPathSelect the root goes back and folders are text', async () => {
const handleGoBack = vi.fn();
await renderPath({ handleGoBack });
it('draws each chunk as a filled tile with a cleaned preview and #n · tokens footer', async () => {
await render();
const card = tile()!;
expect(card.dataset.variant).toBe('filled');
expect(card.dataset.padding).toBe('lg');
expect(card.querySelector('p')!.textContent).toBe('Late pickup clause');
const footer = card.querySelector('[data-slot="card-footer"]')!;
expect(footer.textContent).toBe(
'settings.sources.chunkTileMeta {"n":1,"tokens":"42"}',
);
// No muted strip on the muted tile: it would disappear.
expect(card.querySelector('.bg-muted')).toBeNull();
});
await act(async () => crumbButton('Contracts')!.click());
it('numbers tiles from the page offset', async () => {
service.getDocumentChunks.mockImplementation(async () =>
chunksResponse({
page: 2,
total: 30,
chunks: [
{ doc_id: 'a', text: 'A', metadata: {} },
{ doc_id: 'b', text: 'B', metadata: {} },
],
}),
);
await render();
const footers = Array.from(
container.querySelectorAll('[data-slot="card-footer"]'),
).map((el) => el.textContent);
expect(footers[0]).toContain('"n":13');
expect(footers[1]).toContain('"n":14');
});
// Decision 79: text tiles follow the container with an auto-fit track that
// can't overflow a container narrower than 400px.
it('lays the tiles out on the guarded auto-fit text-tile grid', async () => {
await render();
expect(tile()!.parentElement!.className).toBe(
'grid grid-cols-1 gap-4 sm:grid-cols-[repeat(auto-fit,minmax(min(400px,100%),1fr))]',
);
});
it('shows the search field, the muted count and Add chunk', async () => {
await render({ embedded: true });
expect(container.textContent).toContain('settings.sources.searchChunks');
expect(container.textContent).toContain(
'settings.sources.chunkCount {"count":1,"formatted":"1"}',
);
expect(buttonByText('settings.sources.addChunk')).toBeDefined();
});
// The header's byline already carries it; only a chunk list inside a file
// tree (whose byline is the tree's totals) repeats the count in the toolbar.
it('standalone: the chunk count shows once, in the byline', async () => {
await render();
const count = 'settings.sources.chunkCount {"count":1,"formatted":"1"}';
expect(container.textContent!.split(count)).toHaveLength(2);
expect(
container.querySelector('p.text-sm.text-muted-foreground')?.textContent,
).toBe(count);
});
it('asks for 12 chunks per page and labels the page-size select', async () => {
service.getDocumentChunks.mockImplementation(async () =>
chunksResponse({ total: 30 }),
);
await render();
expect(service.getDocumentChunks.mock.calls[0][2]).toBe(12);
const pager = container.querySelector('[data-slot="pagination"]')!;
expect(pager.textContent).toContain('pagination.chunksPerPage');
});
// Bugs row 1: at 48 per page a 30-chunk source still keeps its pager, so a
// smaller page size can be picked again.
it('keeps the pager when the page size outgrows the chunk count', async () => {
service.getDocumentChunks.mockImplementation(
async (_id: string, _page: number, perPage: number) =>
chunksResponse({ total: 30, per_page: perPage }),
);
await render();
await act(async () => pagerProps.current!.onPageSizeChange!(48));
expect(service.getDocumentChunks.mock.lastCall![2]).toBe(48);
const pager = container.querySelector('[data-slot="pagination"]');
expect(pager).not.toBeNull();
expect(pager!.textContent).toContain('pagination.chunksPerPage');
});
it('draws no pager for 12 chunks or fewer', async () => {
service.getDocumentChunks.mockImplementation(async () =>
chunksResponse({ total: 12 }),
);
await render();
expect(container.querySelector('[data-slot="pagination"]')).toBeNull();
});
it('standalone: Sources › the source, and the Sources crumb leaves it', async () => {
const handleGoBack = vi.fn();
await render({ handleGoBack, headerAction: <span>retrieval</span> });
expect(crumbs()).toEqual(['settings.sources.label', 'Contracts']);
expect(container.textContent).toContain('retrieval');
await clickCrumb('settings.sources.label');
expect(handleGoBack).toHaveBeenCalledTimes(1);
expect(crumbButton('legal')).toBeUndefined();
expect(crumbButton('2024')).toBeUndefined();
});
it('embedded: draws no header', async () => {
await render({ embedded: true });
expect(container.querySelector('[data-slot="breadcrumb-item"]')).toBeNull();
});
const drawer = () => document.body.querySelector('[role="dialog"]');
// Chunk n of a 3-chunk list served one per page.
const THREE = ['# First', '# Second', '# Third'];
const serveThree = () =>
service.getDocumentChunks.mockImplementation(
async (_id: string, page: number, perPage: number) =>
perPage === 1
? chunksResponse({
page,
per_page: 1,
total: 3,
chunks: [
{
doc_id: `c${page}`,
text: THREE[page - 1],
metadata: { token_count: page },
},
],
})
: chunksResponse({
total: 3,
chunks: THREE.map((text, i) => ({
doc_id: `c${i + 1}`,
text,
metadata: { token_count: i + 1 },
})),
}),
);
it('opens a chunk rendered in the reader panel and returns to the list', async () => {
const controllerRef: React.ComponentProps<typeof Chunks>['controllerRef'] =
{ current: null };
const onOpenChunkChange = vi.fn();
await render({ embedded: true, controllerRef, onOpenChunkChange });
await act(async () => tile()!.click());
expect(tile()).toBeNull();
expect(container.textContent).toContain(
'settings.sources.chunkPosition {"n":1,"total":1,"tokens":"42"}',
);
// Markdown reads rendered: no textarea, the heading is a heading.
expect(container.querySelector('textarea')).toBeNull();
expect(container.querySelector('h2')?.textContent).toBe(
'Late pickup clause',
);
// The host's crumbs close it; the reader has no Back of its own.
expect(onOpenChunkChange).toHaveBeenLastCalledWith(1);
expect(container.querySelector('.lucide-arrow-left')).toBeNull();
await act(async () => controllerRef.current!.closeChunk());
expect(tile()).not.toBeNull();
expect(onOpenChunkChange).toHaveBeenLastCalledWith(null);
});
it('renders a markdown heading as an h1', async () => {
service.getDocumentChunks.mockImplementation(async () =>
chunksResponse({
chunks: [{ doc_id: 'c1', text: '# Rates\n\nBody', metadata: {} }],
}),
);
await render({ embedded: true });
await act(async () => tile()!.click());
expect(container.querySelector('h1')?.textContent).toBe('Rates');
});
it('standalone: an open chunk is the last crumb and the source crumb closes it', async () => {
const handleGoBack = vi.fn();
await render({ handleGoBack });
await act(async () => tile()!.click());
expect(crumbs()).toEqual([
'settings.sources.label',
'Contracts',
'settings.sources.chunkCrumb {"n":1}',
]);
await clickCrumb('Contracts');
expect(handleGoBack).not.toHaveBeenCalled();
expect(tile()).not.toBeNull();
expect(crumbs()).toEqual(['settings.sources.label', 'Contracts']);
});
it('previous / next fetch chunk n one per page and stop at the ends', async () => {
serveThree();
await render({ embedded: true, path: 'a/b.md' });
await act(async () => tile()!.click());
const prev = () => buttonByLabel('settings.sources.previousChunk')!;
const next = () => buttonByLabel('settings.sources.nextChunk')!;
expect(prev().disabled).toBe(true);
expect(next().disabled).toBe(false);
service.getDocumentChunks.mockClear();
await act(async () => next().click());
expect(service.getDocumentChunks).toHaveBeenCalledWith(
'doc',
2,
1,
null,
'a/b.md',
'',
);
expect(container.querySelector('h1')?.textContent).toBe('Second');
expect(container.textContent).toContain(
'settings.sources.chunkPosition {"n":2,"total":3,"tokens":"2"}',
);
await act(async () => next().click());
expect(container.querySelector('h1')?.textContent).toBe('Third');
expect(next().disabled).toBe(true);
expect(prev().disabled).toBe(false);
await act(async () => prev().click());
expect(service.getDocumentChunks).toHaveBeenLastCalledWith(
'doc',
2,
1,
null,
'a/b.md',
'',
);
expect(container.querySelector('h1')?.textContent).toBe('Second');
});
it('the arrow keys walk the chunks while no field has focus', async () => {
serveThree();
await render({ embedded: true });
await act(async () => tile()!.click());
await act(async () => {
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight' }));
});
expect(container.querySelector('h1')?.textContent).toBe('Second');
await act(async () => {
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft' }));
});
expect(container.querySelector('h1')?.textContent).toBe('First');
});
// 13 chunks, 12 per page: chunk 13 sits on page 2.
const serveThirteen = () =>
service.getDocumentChunks.mockImplementation(
async (_id: string, page: number, perPage: number) =>
perPage === 1
? chunksResponse({
page,
per_page: 1,
total: 13,
chunks: [
{ doc_id: `c${page}`, text: `# C${page}`, metadata: {} },
],
})
: chunksResponse({
page,
total: 13,
chunks: Array.from({ length: page === 2 ? 1 : 12 }, (_, i) => ({
doc_id: `p${page}-${i}`,
text: `tile ${page}-${i}`,
metadata: {},
})),
}),
);
it('back to the grid lands on the page holding the last chunk', async () => {
const controllerRef: React.ComponentProps<typeof Chunks>['controllerRef'] =
{ current: null };
serveThirteen();
await render({ embedded: true, controllerRef });
const tiles = container.querySelectorAll<HTMLButtonElement>(
'button[data-slot="card"]',
);
await act(async () => tiles[11].click());
await act(async () => buttonByLabel('settings.sources.nextChunk')!.click());
expect(container.querySelector('h1')?.textContent).toBe('C13');
await act(async () => controllerRef.current!.closeChunk());
const lastGridCall = service.getDocumentChunks.mock.calls
.filter((call) => call[2] !== 1)
.pop()!;
expect(lastGridCall[1]).toBe(2);
expect(tile()!.textContent).toContain('tile 2-0');
});
it('Edit opens the drawer with the chunk text and saves it', async () => {
service.getDocumentChunks.mockImplementation(async () =>
chunksResponse({
chunks: [
{
doc_id: 'c1',
text: '## Late pickup clause',
metadata: { token_count: 42, title: 'Clause' },
},
],
}),
);
service.updateChunk.mockImplementation(async () => ({
ok: true,
json: async () => ({ chunk_id: 'c1-new' }),
}));
await render({ embedded: true, path: 'legal/msa.pdf' });
await act(async () => tile()!.click());
// No in-panel edit mode any more.
expect(buttonByText('modals.chunk.cancel')).toBeUndefined();
await act(async () => buttonByText('modals.chunk.edit')!.click());
expect(drawer()).not.toBeNull();
expect(drawer()!.textContent).toContain('settings.sources.editChunk');
expect(drawer()!.textContent).toContain(
'settings.sources.editChunkDescription {"file":"legal/msa.pdf","n":1,"tokens":"42"',
);
const field = drawer()!.querySelector('textarea')!;
expect(field.value).toBe('## Late pickup clause');
await act(async () => setFieldValue(field, '## New text'));
service.getDocumentChunks.mockClear();
await act(async () => buttonByText('modals.chunk.save')!.click());
expect(service.updateChunk).toHaveBeenCalledWith(
{
id: 'doc',
chunk_id: 'c1',
text: '## New text',
metadata: { title: 'Clause' },
},
null,
);
expect(drawer()).toBeNull();
// That chunk and the grid page are fetched again.
const sizes = service.getDocumentChunks.mock.calls.map((call) => call[2]);
expect(sizes).toContain(1);
expect(sizes).toContain(12);
});
it('an edit the search no longer matches still shows the saved text', async () => {
service.updateChunk.mockImplementation(async () => ({
ok: true,
json: async () => ({ chunk_id: 'c1-new' }),
}));
await render({ embedded: true });
await act(async () => tile()!.click());
await act(async () => buttonByText('modals.chunk.edit')!.click());
// After the save, the open position holds nothing (or another chunk).
service.getDocumentChunks.mockImplementation(async () =>
chunksResponse({ total: 0, chunks: [] }),
);
const field = drawer()!.querySelector('textarea')!;
await act(async () => setFieldValue(field, '## Rewritten'));
await act(async () => buttonByText('modals.chunk.save')!.click());
expect(drawer()).toBeNull();
expect(container.querySelector('h2')?.textContent).toBe('Rewritten');
expect(container.textContent).toContain('chunkPositionUnplaced');
expect(buttonByLabel('settings.sources.nextChunk')!.disabled).toBe(true);
});
it('deleting the only chunk on the last page lands on the page before', async () => {
const controllerRef: React.ComponentProps<typeof Chunks>['controllerRef'] =
{ current: null };
serveThirteen();
service.deleteChunk.mockImplementation(async () => ({ ok: true }));
await render({ embedded: true, controllerRef });
const tiles = container.querySelectorAll<HTMLButtonElement>(
'button[data-slot="card"]',
);
await act(async () => tiles[11].click());
await act(async () => buttonByLabel('settings.sources.nextChunk')!.click());
expect(container.querySelector('h1')?.textContent).toBe('C13');
const trigger = buttonByLabel('settings.sources.menuAlt')!;
await act(async () => {
trigger.dispatchEvent(
new PointerEvent('pointerdown', { bubbles: true, button: 0 }),
);
trigger.click();
});
const del = Array.from(
document.querySelectorAll<HTMLElement>('[role="menuitem"]'),
).find((el) => el.textContent === 'modals.chunk.delete')!;
await act(async () => del.click());
await act(async () => buttonByText('modals.chunk.delete')!.click());
expect(service.deleteChunk).toHaveBeenCalledWith('doc', 'c13', null);
const lastGridCall = service.getDocumentChunks.mock.calls
.filter((call) => call[2] !== 1)
.pop()!;
expect(lastGridCall[1]).toBe(1);
});
it('drops a grid response that a newer fetch overtook', async () => {
const controllerRef: React.ComponentProps<typeof Chunks>['controllerRef'] =
{ current: null };
serveThirteen();
await render({ embedded: true, controllerRef });
// Open chunk 13 (page 2), then start a slow page-1 refresh and close the
// chunk before it answers.
const tiles = container.querySelectorAll<HTMLButtonElement>(
'button[data-slot="card"]',
);
await act(async () => tiles[11].click());
await act(async () => buttonByLabel('settings.sources.nextChunk')!.click());
let release: () => void = () => undefined;
const slow = new Promise<void>((resolve) => {
release = resolve;
});
const fast = service.getDocumentChunks.getMockImplementation()! as (
...a: unknown[]
) => unknown;
// Grid page 1 answers only once released.
service.getDocumentChunks.mockImplementation(async (...args: unknown[]) => {
if (args[1] === 1 && args[2] !== 1) await slow;
return fast(...args);
});
service.updateChunk.mockImplementation(async () => ({
ok: true,
json: async () => ({ chunk_id: 'c13' }),
}));
await act(async () => buttonByText('modals.chunk.edit')!.click());
const field = drawer()!.querySelector('textarea')!;
await act(async () => setFieldValue(field, '# C13 edited'));
await act(async () => buttonByText('modals.chunk.save')!.click());
await act(async () => controllerRef.current!.closeChunk());
expect(tile()!.textContent).toContain('tile 2-0');
await act(async () => release());
expect(tile()!.textContent).toContain('tile 2-0');
});
it('a failed save keeps the drawer open with a destructive alert', async () => {
service.updateChunk.mockImplementation(async () => ({ ok: false }));
await render({ embedded: true });
await act(async () => tile()!.click());
await act(async () => buttonByText('modals.chunk.edit')!.click());
const field = drawer()!.querySelector('textarea')!;
await act(async () => setFieldValue(field, 'New text'));
await act(async () => buttonByText('modals.chunk.save')!.click());
expect(service.updateChunk).toHaveBeenCalledTimes(1);
expect(drawer()).not.toBeNull();
const alert = drawer()!.querySelector('[data-slot="alert"]')!;
expect(alert.getAttribute('data-variant')).toBe('destructive');
expect(alert.textContent).toBe('settings.sources.chunkErrors.save');
expect(dispatch).not.toHaveBeenCalled();
// The edits stay in the drawer.
expect(drawer()!.querySelector('textarea')!.value).toBe('New text');
});
it('Add chunk opens the drawer empty, adds and stays on the grid', async () => {
service.addChunk.mockImplementation(async () => ({ ok: true }));
await render({ embedded: true, path: 'a.md' });
await act(async () => buttonByText('settings.sources.addChunk')!.click());
expect(
drawer()!.querySelector('h2, [data-slot="sheet-title"]')!.textContent,
).toBe('settings.sources.addChunk');
const field = drawer()!.querySelector('textarea')!;
expect(field.value).toBe('');
// The title defaults to the file's name, as ingest sets it.
expect(drawer()!.querySelector('input')!.value).toBe('a.md');
await act(async () => setFieldValue(field, 'Fresh'));
service.getDocumentChunks.mockClear();
await act(async () => buttonByText('modals.chunk.add')!.click());
expect(service.addChunk).toHaveBeenCalledWith(
{
id: 'doc',
text: 'Fresh',
metadata: { source: 'a.md', source_id: 'doc', title: 'a.md' },
},
null,
);
expect(drawer()).toBeNull();
expect(service.getDocumentChunks).toHaveBeenCalledTimes(1);
expect(tile()).not.toBeNull();
});
it("Add chunk's title follows the file's other chunks, then its display name", async () => {
service.getDocumentChunks.mockImplementation(async () =>
chunksResponse({
chunks: [
{ doc_id: 'c1', text: 'x', metadata: { title: 'Carrier profile' } },
],
}),
);
await render({
embedded: true,
path: 'x_1.docx',
fileName: 'Carrier.docx',
});
await act(async () => buttonByText('settings.sources.addChunk')!.click());
expect(drawer()!.querySelector('input')!.value).toBe('Carrier profile');
await act(async () =>
buttonByText('settings.sources.editor.cancel')!.click(),
);
service.getDocumentChunks.mockImplementation(async () => chunksResponse());
await render({ embedded: true, path: 'x_2.docx', fileName: 'Other.docx' });
await act(async () => buttonByText('settings.sources.addChunk')!.click());
expect(drawer()!.querySelector('input')!.value).toBe('Other.docx');
});
it('editing only the title enables Save and sends it', async () => {
service.getDocumentChunks.mockImplementation(async () =>
chunksResponse({
chunks: [
{ doc_id: 'c1', text: 'Body', metadata: { title: 'Old name' } },
],
}),
);
service.updateChunk.mockImplementation(async () => ({
ok: true,
json: async () => ({ chunk_id: 'c1-new' }),
}));
await render({ embedded: true });
await act(async () => tile()!.click());
await act(async () => buttonByText('modals.chunk.edit')!.click());
const title = drawer()!.querySelector('input')!;
expect(title.value).toBe('Old name');
expect(buttonByText('modals.chunk.save')!.disabled).toBe(true);
await act(async () => setFieldValue(title, 'New name'));
expect(buttonByText('modals.chunk.save')!.disabled).toBe(false);
await act(async () => buttonByText('modals.chunk.save')!.click());
expect(service.updateChunk).toHaveBeenCalledWith(
{
id: 'doc',
chunk_id: 'c1',
text: 'Body',
metadata: { title: 'New name' },
},
null,
);
});
it('saving an untitled chunk sends no empty title', async () => {
service.updateChunk.mockImplementation(async () => ({
ok: true,
json: async () => ({ chunk_id: 'c1-new' }),
}));
await render({ embedded: true });
await act(async () => tile()!.click());
await act(async () => buttonByText('modals.chunk.edit')!.click());
await act(async () =>
setFieldValue(drawer()!.querySelector('textarea')!, 'Rewritten'),
);
await act(async () => buttonByText('modals.chunk.save')!.click());
expect(service.updateChunk).toHaveBeenCalledWith(
{ id: 'doc', chunk_id: 'c1', text: 'Rewritten' },
null,
);
});
it('an Add drawer opened before the grid lands closes without a discard prompt', async () => {
let release: () => void = () => undefined;
const slow = new Promise<void>((resolve) => {
release = resolve;
});
service.getDocumentChunks.mockImplementation(async () => {
await slow;
return chunksResponse({
chunks: [{ doc_id: 'c1', text: 'x', metadata: { title: 'Profile' } }],
});
});
await render({ embedded: true, fileName: 'Carrier.docx' });
await act(async () => buttonByText('settings.sources.addChunk')!.click());
expect(drawer()!.querySelector('input')!.value).toBe('Carrier.docx');
await act(async () => release());
await act(async () =>
buttonByText('settings.sources.editor.cancel')!.click(),
);
expect(document.body.textContent).not.toContain(
'settings.sources.editor.discardMessage',
);
expect(drawer()).toBeNull();
});
/**
* A 3-chunk store the tests reorder: an update adds the new copy where
* `placeAt` says (the end by default) and drops the old one.
*/
const serveStore = (placeAt?: number) => {
const store = [
{ doc_id: 'c1', text: '# First', metadata: {} },
{ doc_id: 'c2', text: '# Second', metadata: {} },
{ doc_id: 'c3', text: '# Third', metadata: {} },
];
service.getDocumentChunks.mockImplementation(
async (_id: string, page: number, perPage: number) => {
const start = (page - 1) * perPage;
return chunksResponse({
page,
per_page: perPage,
total: store.length,
chunks: store.slice(start, start + perPage),
});
},
);
service.updateChunk.mockImplementation(
async (body: { chunk_id: string; text: string }) => {
const index = store.findIndex((c) => c.doc_id === body.chunk_id);
const [old] = store.splice(index, 1);
const copy = { ...old, doc_id: `${old.doc_id}-new`, text: body.text };
store.splice(placeAt ?? store.length, 0, copy);
return { ok: true, json: async () => ({ chunk_id: copy.doc_id }) };
},
);
};
const editOpenChunk = async (text: string) => {
await act(async () => buttonByText('modals.chunk.edit')!.click());
await act(async () =>
setFieldValue(drawer()!.querySelector('textarea')!, text),
);
await act(async () => buttonByText('modals.chunk.save')!.click());
};
const position = () =>
container
.textContent!.match(/chunkPosition \{"n":(\d+),"total":(\d+)/)!
.slice(1)
.map(Number);
it('an edited chunk the store moved to the end is shown at its new position', async () => {
serveStore();
await render({ embedded: true });
await act(async () => tile()!.click());
await editOpenChunk('# First edited');
expect(container.querySelector('h1')?.textContent).toBe('First edited');
expect(position()).toEqual([3, 3]);
expect(buttonByLabel('settings.sources.nextChunk')!.disabled).toBe(true);
// Previous walks the store's current order: nothing is skipped.
await act(async () =>
buttonByLabel('settings.sources.previousChunk')!.click(),
);
expect(container.querySelector('h1')?.textContent).toBe('Third');
await act(async () =>
buttonByLabel('settings.sources.previousChunk')!.click(),
);
expect(container.querySelector('h1')?.textContent).toBe('Second');
});
it('an edited chunk the store moved off the probed positions keeps its text but not its place', async () => {
serveStore(1);
await render({ embedded: true });
const tiles = container.querySelectorAll<HTMLButtonElement>(
'button[data-slot="card"]',
);
await act(async () => tiles[0].click());
service.getDocumentChunks.mockClear();
await editOpenChunk('# First edited');
expect(container.querySelector('h1')?.textContent).toBe('First edited');
// Its position is unknown: no number is claimed and paging is off, so
// previous / next can't step from a place the chunk no longer holds.
expect(container.textContent).not.toMatch(/chunkPosition \{/);
expect(container.textContent).toContain('chunkPositionUnplaced');
expect(buttonByLabel('settings.sources.previousChunk')!.disabled).toBe(
true,
);
expect(buttonByLabel('settings.sources.nextChunk')!.disabled).toBe(true);
// Only the open and last positions are probed: the whole filtered list
// (one page of `total`) is never fetched.
const sizes = service.getDocumentChunks.mock.calls.map(
(call: unknown[]) => call[2],
);
expect(sizes).toContain(1);
expect(sizes).not.toContain(3);
});
it('an edited chunk that keeps its place stays put', async () => {
serveStore(0);
await render({ embedded: true });
await act(async () => tile()!.click());
await editOpenChunk('# First edited');
expect(position()).toEqual([1, 3]);
await act(async () => buttonByLabel('settings.sources.nextChunk')!.click());
expect(container.querySelector('h1')?.textContent).toBe('Second');
});
it('shows a destructive empty state with Retry when the fetch fails', async () => {
service.getDocumentChunks.mockImplementation(async () => ({ ok: false }));
await render();
const alert = container.querySelector('[role="alert"]')!;
expect(alert).not.toBeNull();
expect(alert.textContent).toContain('settings.sources.chunkErrors.load');
expect(tile()).toBeNull();
service.getDocumentChunks.mockImplementation(async () => chunksResponse());
await act(async () => buttonByText('retry')!.click());
expect(tile()).not.toBeNull();
});
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,147 @@
import { act, useState } from 'react';
import { createRoot, type Root } from 'react-dom/client';
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));
vi.mock('react-redux', () => ({
useSelector: () => null,
useDispatch: () => vi.fn(),
useStore: () => ({ getState: () => ({}), subscribe: () => () => {} }),
}));
vi.mock('../hooks', () => ({
useDarkTheme: () => [false],
useDebouncedValue: (value: unknown) => value,
useLoaderState: (initial: boolean) => useState(initial),
useMediaQuery: () => ({ isMobile: false, isDesktop: true }),
useOutsideAlerter: () => undefined,
}));
vi.mock('../api/services/userService', () => ({
default: {
getDirectoryStructure: vi.fn(async () => ({
json: async () => ({
provider: 'google_drive',
directory_structure: {
'a.docx': { type: 'docx' },
'b.docx': { type: 'docx' },
},
}),
})),
getDocumentChunks: vi.fn(async () => ({
ok: true,
json: async () => ({ page: 1, per_page: 12, total: 0, chunks: [] }),
})),
},
}));
import ConnectorTree, { providerLabel } from './ConnectorTree';
import FileTree from './FileTree';
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true });
describe('providerLabel', () => {
it('names known providers and title-cases the rest', () => {
expect(providerLabel('google_drive')).toBe('Google Drive');
expect(providerLabel('share_point')).toBe('SharePoint');
expect(providerLabel('box_sync')).toBe('Box Sync');
});
});
describe('ConnectorTree and FileTree headers', () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
});
const render = async (node: React.ReactNode) => {
await act(async () => root.render(node));
};
const badge = () => container.querySelector('[data-slot="badge"]');
const retrieval = <button type="button">retrieval</button>;
it('ConnectorTree shows the provider badge, headerAction and Sync', async () => {
await render(
<ConnectorTree
docId="doc"
sourceName="Drive"
onBackToDocuments={vi.fn()}
headerAction={retrieval}
/>,
);
expect(badge()!.textContent).toBe('Google Drive');
expect(badge()!.getAttribute('data-variant')).toBe('neutral');
expect(container.textContent).toContain('retrieval');
expect(container.textContent).toContain('settings.sources.sync');
});
it('embedded ConnectorTree drops the badge and headerAction, keeps Sync', async () => {
await render(
<ConnectorTree
embedded
docId="doc"
sourceName="Drive"
onBackToDocuments={vi.fn()}
headerAction={retrieval}
/>,
);
expect(badge()).toBeNull();
expect(container.textContent).not.toContain('retrieval');
expect(container.textContent).toContain('settings.sources.sync');
expect(container.textContent).not.toContain('settings.sources.label');
});
it('embedded FileTree drops headerAction, keeps Add file', async () => {
await render(
<FileTree
embedded
docId="doc"
sourceName="Files"
onBackToDocuments={vi.fn()}
headerAction={retrieval}
/>,
);
expect(badge()).toBeNull();
expect(container.textContent).not.toContain('retrieval');
expect(container.textContent).toContain('settings.sources.addFile');
});
const crumbs = () =>
Array.from(container.querySelectorAll('[data-slot="breadcrumb-item"]')).map(
(el) => el.textContent,
);
it('ConnectorTree and FileTree open the initialPath file', async () => {
await render(
<ConnectorTree
docId="doc"
sourceName="Drive"
onBackToDocuments={vi.fn()}
initialPath="b.docx"
/>,
);
expect(crumbs()).toEqual(['settings.sources.label', 'Drive', 'b.docx']);
await render(
<FileTree
docId="doc"
sourceName="Files"
onBackToDocuments={vi.fn()}
initialPath="a.docx"
/>,
);
expect(crumbs()).toEqual(['settings.sources.label', 'Files', 'a.docx']);
});
});
+53 -10
View File
@@ -1,14 +1,16 @@
import React, { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import { Check, RefreshCw } from 'lucide-react';
import { Check, Cloud, RefreshCw } from 'lucide-react';
import userService from '../api/services/userService';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import ConfirmationModal from '../modals/ConfirmationModal';
import { ActiveState } from '../models/misc';
import { selectToken } from '../preferences/preferenceSlice';
import type { Crumb } from './tree/PathHeader';
import TreeBrowser from './tree/TreeBrowser';
import type { TreeBrowserController } from './tree/types';
import { useReingestSseWaiter } from './tree/useReingestWait';
@@ -19,6 +21,38 @@ interface ConnectorTreeProps {
onBackToDocuments: () => void;
/** Extra header control, rendered left of the Sync button. */
headerAction?: React.ReactNode;
/**
* Inside another source view (the graph source's Files tab): no Sources
* crumb, badge or byline, and no headerAction; Sync stays.
*/
embedded?: boolean;
/** Embedded only: the host header's action slot (see TreeBrowser). */
actionsTarget?: HTMLElement | null;
/** A file to open once the structure loads (path, file name or display name). */
initialPath?: string;
/** Embedded only: the tree's crumbs, for the host's header (see TreeBrowser). */
onCrumbsChange?: (crumbs: Crumb[]) => void;
}
// Provider names are brand names, so they are not translated.
const PROVIDER_LABELS: Record<string, string> = {
google_drive: 'Google Drive',
share_point: 'SharePoint',
confluence: 'Confluence',
};
/**
* The display name of a connector provider: a known brand name, else the raw
* id with underscores as spaces, title-cased ("box_sync" → "Box Sync").
*/
export function providerLabel(provider: string): string {
const known = PROVIDER_LABELS[provider.toLowerCase()];
if (known) return known;
return provider
.split('_')
.filter(Boolean)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ');
}
const ConnectorTree: React.FC<ConnectorTreeProps> = ({
@@ -26,6 +60,10 @@ const ConnectorTree: React.FC<ConnectorTreeProps> = ({
sourceName,
onBackToDocuments,
headerAction,
embedded = false,
actionsTarget,
initialPath,
onCrumbsChange,
}) => {
const { t } = useTranslation();
const token = useSelector(selectToken);
@@ -106,20 +144,13 @@ const ConnectorTree: React.FC<ConnectorTreeProps> = ({
const topRightAction = (
<>
{headerAction}
{embedded ? null : headerAction}
<Button
type="button"
size="field"
shape="pill"
onClick={() => setSyncConfirmationModal('ACTIVE')}
disabled={isSyncing}
title={
isSyncing
? `${t('settings.sources.syncing')} ${syncProgress}%`
: syncDone
? 'Done'
: t('settings.sources.sync')
}
>
{syncDone ? (
<Check />
@@ -133,7 +164,7 @@ const ConnectorTree: React.FC<ConnectorTreeProps> = ({
{isSyncing
? `${syncProgress}%`
: syncDone
? 'Done'
? t('settings.sources.syncDone')
: t('settings.sources.sync')}
</Button>
</>
@@ -155,6 +186,18 @@ const ConnectorTree: React.FC<ConnectorTreeProps> = ({
docId={docId}
sourceName={sourceName}
onBackToDocuments={onBackToDocuments}
embedded={embedded}
onCrumbsChange={onCrumbsChange}
actionsTarget={actionsTarget}
initialPath={initialPath}
badge={
sourceProvider ? (
<Badge variant="neutral">
<Cloud />
{providerLabel(sourceProvider)}
</Badge>
) : undefined
}
columnOrder="tokens-first"
sortEntries
controllerRef={controllerRef}
+21 -1
View File
@@ -6,6 +6,7 @@ import { Trash2 } from 'lucide-react';
import { SOURCE_FILE_TREE_ACCEPT_ATTR } from '../constants/fileUpload';
import ConfirmationModal from '../modals/ConfirmationModal';
import { selectToken } from '../preferences/preferenceSlice';
import type { Crumb } from './tree/PathHeader';
import TreeBrowser from './tree/TreeBrowser';
import { Button } from './ui/button';
import type { MenuOption } from './ui/dropdown-menu';
@@ -26,6 +27,17 @@ interface FileTreeProps {
onBackToDocuments: () => void;
/** Extra header control, rendered left of "Add file". */
headerAction?: React.ReactNode;
/**
* Inside another source view (the graph source's Files tab): no Sources
* crumb, badge or byline, and no headerAction; Add file stays.
*/
embedded?: boolean;
/** Embedded only: the host header's action slot (see TreeBrowser). */
actionsTarget?: HTMLElement | null;
/** A file to open once the structure loads (path, file name or display name). */
initialPath?: string;
/** Embedded only: the tree's crumbs, for the host's header (see TreeBrowser). */
onCrumbsChange?: (crumbs: Crumb[]) => void;
}
const FileTree: React.FC<FileTreeProps> = ({
@@ -33,6 +45,10 @@ const FileTree: React.FC<FileTreeProps> = ({
sourceName,
onBackToDocuments,
headerAction,
embedded = false,
actionsTarget,
initialPath,
onCrumbsChange,
}) => {
const { t } = useTranslation();
const token = useSelector(selectToken);
@@ -231,7 +247,7 @@ const FileTree: React.FC<FileTreeProps> = ({
// Add file button is suppressed then.
const topRightAction = (
<>
{headerAction}
{embedded ? null : headerAction}
{!isProcessing ? (
<Button type="button" size="field" shape="pill" onClick={handleAddFile}>
{t('settings.sources.addFile')}
@@ -263,6 +279,10 @@ const FileTree: React.FC<FileTreeProps> = ({
docId={docId}
sourceName={sourceName}
onBackToDocuments={onBackToDocuments}
embedded={embedded}
onCrumbsChange={onCrumbsChange}
actionsTarget={actionsTarget}
initialPath={initialPath}
columnOrder="size-first"
sortEntries={false}
controllerRef={controllerRef}
@@ -65,6 +65,28 @@ describe('FileUpload', () => {
).toBe('compact');
});
it('shows the current image inside a tile until a new one is picked', async () => {
await act(async () => {
root.render(
<FileUpload
onUpload={vi.fn()}
size="tile"
showPreview
currentImage="https://example.com/agent.png"
uploadText="Avatar"
/>,
);
});
const zone = container.querySelector('[data-slot="dropzone"]')!;
expect(zone.getAttribute('data-size')).toBe('tile');
const img = zone.querySelector('img')!;
expect(img.getAttribute('src')).toBe('https://example.com/agent.png');
expect(img.className).toContain('object-cover');
expect(container.textContent).not.toContain(
'components.fileUpload.fileTypes',
);
});
it('renders highlighted upload text segments', async () => {
await act(async () => {
root.render(
+37 -2
View File
@@ -21,7 +21,11 @@ interface FileUploadProps {
showPreview?: boolean;
previewSize?: number;
/** `compact` is a one-row target for forms and panels. */
size?: 'default' | 'compact';
size?: 'default' | 'compact' | 'tile';
/** A tile's width; `fixed` keeps it 64px in a narrow drawer. */
tileSize?: 'responsive' | 'fixed';
/** An image already saved (an agent's avatar), shown in a tile until a new one is picked. */
currentImage?: string;
children?: React.ReactNode;
/** Layout classes only; the Dropzone owns colours, border and radius. */
@@ -53,6 +57,8 @@ export const FileUpload = ({
showPreview = false,
previewSize = 80,
size = 'default',
tileSize,
currentImage,
children,
className,
uploadText,
@@ -196,8 +202,36 @@ export const FileUpload = ({
// With a preview, the image takes the icon's place; the text block mirrors
// the Dropzone's own title/description layout.
// A tile shows the picked image (with Remove) or the saved one filling it.
const tileImage = preview ?? currentImage;
const tileContent =
size === 'tile' && showPreview && tileImage ? (
<span className="relative size-full">
<img
src={tileImage}
alt={t('components.fileUpload.preview')}
className="size-full rounded-xl object-cover"
/>
{preview ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleRemove();
}}
className="bg-primary hover:bg-primary/90 absolute -top-2 -right-2 rounded-full p-1 transition-colors"
aria-label={t('components.fileUpload.remove')}
>
<X className="text-primary-foreground size-3" />
</button>
) : null}
</span>
) : undefined;
const previewContent =
showPreview && preview ? (
size === 'tile' ? (
tileContent
) : showPreview && preview ? (
<>
{renderPreview()}
<span className="flex min-w-0 flex-col gap-0.5">
@@ -225,6 +259,7 @@ export const FileUpload = ({
maxSize={maxSize}
disabled={disabled}
size={size}
tileSize={tileSize}
title={title}
description={description}
icon={<ImageUp />}
+587 -256
View File
@@ -1,103 +1,193 @@
import { forceCollide, type SimulationNodeDatum } from 'd3-force';
import { ArrowLeft, Network, X } from 'lucide-react';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import React, {
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from 'react';
import { useTranslation } from 'react-i18next';
import { useSelector } from 'react-redux';
import ForceGraph2D, { type ForceGraphMethods } from 'react-force-graph-2d';
import ForceGraph2D, {
type ForceGraphMethods,
type LinkObject,
type NodeObject,
} from 'react-force-graph-2d';
import userService from '../api/services/userService';
import { selectToken } from '../preferences/preferenceSlice';
import { useMediaQuery } from '../hooks';
import { useThemeVersion } from '../utils/chartUtils';
import { Badge } from './ui/badge';
import { IconButton } from './ui/icon-button';
import { SectionHeader } from './ui/section-header';
import SkeletonLoader from './SkeletonLoader';
import { formatCount } from '../utils/dateTimeUtils';
import GraphCanvasControls from './graph/GraphCanvasControls';
import GraphEntitySearch from './graph/GraphEntitySearch';
import GraphNodePanel, {
GraphNodePanelDock,
type GraphNodeRef,
} from './graph/GraphNodePanel';
import { GraphSeriesDot } from './graph/GraphTypeDot';
import {
ForceGraphData,
GraphNode,
GraphNodeDetail,
GraphOverview,
DIM_ALPHA,
OTHER_GROUP_KEY,
buildAdjacency,
endpointId,
focusSet,
legendGroupOf,
linkTouches,
nodeHasLabel,
pickLabels,
topHubIds,
type LabelBox,
} from './graph/graphCanvasUtils';
import { useGraphNodeDetail } from './graph/useGraphNodeDetail';
import { Button } from './ui/button';
import { Card } from './ui/card';
import { EmptyState } from './ui/empty-state';
import { LoadingState } from './ui/loading-state';
import { Sheet, SheetContent } from './ui/sheet';
import { ToggleGroup, ToggleGroupItem } from './ui/toggle-group';
import {
type FoldedGraphTypes,
type ForceGraphData,
type GraphEdge,
type GraphNode,
collideRadius,
escapeDeselects,
maxDegree,
nodeAtPoint,
nodeRadius,
otherTypesList,
readGraphPalette,
toForceGraphData,
} from './graphViewUtils';
/** The overview sizes the "Show top" control offers. */
const GRAPH_LIMITS = [50, 100, 250] as const;
export type GraphLoadStatus = 'loading' | 'error' | 'ready';
interface GraphViewProps {
docId: string;
sourceName: string;
onBackToDocuments: () => void;
/** Extra header control, right-aligned in the title row. */
headerAction?: React.ReactNode;
/** The loaded overview (top nodes by degree and the edges among them). */
data: ForceGraphData;
/** The one type fold shared with the header, the entity list and the panel. */
fold: FoldedGraphTypes;
status: GraphLoadStatus;
onRetry: () => void;
limit: number;
onLimitChange: (limit: number) => void;
selected: GraphNodeRef | null;
onSelect: (node: GraphNodeRef | null) => void;
/** False while another tab is showing; Escape then leaves the selection alone. */
active?: boolean;
/** Show a chunk's file on the Files tab (the chunk drawer's "Open in Files"). */
onOpenInFiles?: (path: string) => void;
}
const GRAPH_LIMIT = 100;
const HIT_SLOP = 4;
type PositionedNode = NodeObject<GraphNode> & { x?: number; y?: number };
const HIT_SLOP = 4;
const ZOOM_STEP = 1.3;
const ZOOM_MS = 200;
const FOCUS_MS = 400;
const FOCUS_ZOOM = 2;
const FIT_PADDING = 40;
const LABEL_PX = 11;
/** Screen-pixel clearance kept between two labels. */
const LABEL_GAP_PX = 2;
/**
* The Graph tab of a knowledge-graph source: entity search, the "Show top"
* size, the type legend (a filter), and the canvas with its controls and the
* docked node panel (a bottom sheet on a phone).
*/
const GraphView: React.FC<GraphViewProps> = ({
docId,
sourceName,
onBackToDocuments,
headerAction,
data,
fold,
status,
onRetry,
limit,
onLimitChange,
selected,
onSelect,
active = true,
onOpenInFiles,
}) => {
const { t } = useTranslation();
const token = useSelector(selectToken);
const [data, setData] = useState<ForceGraphData>({ nodes: [], links: [] });
const [loading, setLoading] = useState(true);
const [selectedNode, setSelectedNode] = useState<GraphNodeDetail | null>(
null,
);
const [loadingNode, setLoadingNode] = useState(false);
const { isDesktop } = useMediaQuery();
const showTopId = useId();
const containerRef = useRef<HTMLDivElement>(null);
const hoveredNodeIdRef = useRef<string | null>(null);
const fgRef = useRef<ForceGraphMethods | undefined>(undefined);
const [size, setSize] = useState({ width: 0, height: 480 });
const fgRef = useRef<ForceGraphMethods<GraphNode, GraphEdge> | undefined>(
undefined,
);
const hoveredIdRef = useRef<string | null>(null);
const fittedRef = useRef(false);
const [size, setSize] = useState({ width: 0, height: 0 });
const [zoom, setZoom] = useState(1);
const [hidden, setHidden] = useState<Set<string>>(() => new Set());
// The canvas can't read CSS variables: resolve the tokens, and re-read them
// whenever the theme changes.
const themeVersion = useThemeVersion();
// themeVersion is not used inside the factory: it only signals a change.
const palette = useMemo(() => readGraphPalette(), [themeVersion]);
const fadedLink = `color-mix(in srgb, ${palette.link} 15%, transparent)`;
useEffect(() => {
let cancelled = false;
setLoading(true);
userService
.getSourceGraph(docId, token, GRAPH_LIMIT)
.then((response) => response.json())
.then((body) => {
if (cancelled) return;
const overview: GraphOverview = {
nodes: body?.nodes ?? [],
edges: body?.edges ?? [],
};
setData(toForceGraphData(overview));
})
.catch((error) => console.error('Error loading graph:', error))
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [docId, token]);
useEffect(() => {
if (!containerRef.current) return;
const element = containerRef.current;
const observer = new ResizeObserver(() => {
setSize({ width: element.clientWidth, height: 480 });
});
observer.observe(element);
setSize({ width: element.clientWidth, height: 480 });
return () => observer.disconnect();
}, [loading, data.nodes.length]);
const selectedId = selected?.id ?? null;
const nodeDetail = useGraphNodeDetail(docId, selectedId);
const maxNodeDegree = useMemo(() => maxDegree(data.nodes), [data.nodes]);
const nodeById = useMemo(
() => new Map(data.nodes.map((node) => [node.id, node])),
[data.nodes],
);
const loadedIds = useMemo(() => new Set(nodeById.keys()), [nodeById]);
const adjacency = useMemo(() => buildAdjacency(data.links), [data.links]);
const focus = useMemo(
() => focusSet(selectedId, adjacency, loadedIds),
[selectedId, adjacency, loadedIds],
);
const legendKeys = useMemo(
() => [
...fold.groups.map((group) => group.key),
...(fold.other.count > 0 ? [OTHER_GROUP_KEY] : []),
],
[fold],
);
const isVisible = useCallback(
(node: GraphNode | undefined) =>
!!node && !hidden.has(legendGroupOf(fold, node.type)),
[fold, hidden],
);
const visibleNodes = useMemo(
() => data.nodes.filter((node) => isVisible(node)),
[data.nodes, isVisible],
);
// The busiest of what's shown keep their labels.
const hubs = useMemo(() => topHubIds(visibleNodes), [visibleNodes]);
// A new overview (another "Show top") starts with a fresh fit.
useEffect(() => {
fittedRef.current = false;
}, [data]);
useEffect(() => {
const element = containerRef.current;
if (!element) return;
const measure = () => {
// A hidden tab measures 0: keep the last size so the canvas survives.
if (element.clientWidth === 0) return;
setSize({ width: element.clientWidth, height: element.clientHeight });
};
const observer = new ResizeObserver(measure);
observer.observe(element);
measure();
return () => observer.disconnect();
}, [status, data.nodes.length]);
const hasCanvas = size.width > 0;
useEffect(() => {
if (!fgRef.current || data.nodes.length === 0) return;
fgRef.current.d3Force(
@@ -107,239 +197,480 @@ const GraphView: React.FC<GraphViewProps> = ({
),
);
fgRef.current.d3ReheatSimulation();
}, [data, maxNodeDegree, size.width]);
}, [data, maxNodeDegree, hasCanvas]);
const handleNodeClick = (node: GraphNode) => {
setLoadingNode(true);
setSelectedNode(null);
userService
.getSourceGraphNode(docId, node.id, token)
.then((response) => response.json())
.then((body) => {
if (body?.node) setSelectedNode(body.node as GraphNodeDetail);
})
.catch((error) => console.error('Error loading graph node:', error))
.finally(() => setLoadingNode(false));
};
// A settled simulation stops drawing, so paint changes once.
const repaint = useCallback(() => {
const fg = fgRef.current;
if (fg) fg.zoom(fg.zoom());
}, []);
useEffect(() => {
repaint();
}, [palette, focus, hidden, fold, repaint]);
const fitView = useCallback(
(durationMs = FOCUS_MS) => {
fgRef.current?.zoomToFit(durationMs, FIT_PADDING, (node) =>
isVisible(node as GraphNode),
);
},
[isVisible],
);
// Centre the selection; zoom in once per new selection. Re-centre when the
// docked panel changes the canvas width.
const zoomedForRef = useRef<string | null>(null);
const hadSelectionRef = useRef(false);
useEffect(() => {
const fg = fgRef.current;
if (!fg) return;
if (!selectedId) {
zoomedForRef.current = null;
if (!hadSelectionRef.current) return;
hadSelectionRef.current = false;
// Deselecting hands the width back to the canvas: refit.
const frame = requestAnimationFrame(() => fitView());
return () => cancelAnimationFrame(frame);
}
hadSelectionRef.current = true;
const node = nodeById.get(selectedId) as PositionedNode | undefined;
if (!node || node.x == null || node.y == null) return;
const { x, y } = node;
const frame = requestAnimationFrame(() => {
fg.centerAt(x, y, FOCUS_MS);
if (zoomedForRef.current !== selectedId) {
zoomedForRef.current = selectedId;
fg.zoom(Math.max(fg.zoom(), FOCUS_ZOOM), FOCUS_MS);
}
});
return () => cancelAnimationFrame(frame);
}, [selectedId, nodeById, size.width, fitView]);
useEffect(() => {
if (!active || !selectedId) return;
const onKeyDown = (event: KeyboardEvent) => {
if (escapeDeselects(event)) onSelect(null);
};
document.addEventListener('keydown', onKeyDown);
return () => document.removeEventListener('keydown', onKeyDown);
}, [active, selectedId, onSelect]);
// Geometric hit test, immune to canvas read-back farbling (e.g. Brave): map
// the pointer into graph coordinates and pick the nearest node directly.
// the pointer into graph coordinates and pick the nearest visible node.
const pickNodeAt = (clientX: number, clientY: number): GraphNode | null => {
const fg = fgRef.current;
if (!fg || !containerRef.current) return null;
const rect = containerRef.current.getBoundingClientRect();
const g = fg.screen2GraphCoords(clientX - rect.left, clientY - rect.top);
return nodeAtPoint(data.nodes, g.x, g.y, maxNodeDegree, HIT_SLOP);
return nodeAtPoint(visibleNodes, g.x, g.y, maxNodeDegree, HIT_SLOP);
};
const repaint = () => {
const fg = fgRef.current;
if (fg) fg.zoom(fg.zoom());
};
// A settled simulation stops drawing, so paint the new colours once.
useEffect(() => {
repaint();
}, [palette]);
const onCanvas = (event: React.MouseEvent) =>
event.target instanceof HTMLCanvasElement;
const handlePointerMove = (event: React.MouseEvent<HTMLDivElement>) => {
const node = pickNodeAt(event.clientX, event.clientY);
const node = onCanvas(event)
? pickNodeAt(event.clientX, event.clientY)
: null;
if (containerRef.current) {
containerRef.current.style.cursor = node ? 'pointer' : 'default';
}
const nextId = node?.id ?? null;
if (nextId !== hoveredNodeIdRef.current) {
hoveredNodeIdRef.current = nextId;
if (nextId !== hoveredIdRef.current) {
hoveredIdRef.current = nextId;
repaint();
}
};
const handlePointerLeave = () => {
if (containerRef.current) containerRef.current.style.cursor = 'default';
if (hoveredNodeIdRef.current !== null) {
hoveredNodeIdRef.current = null;
if (hoveredIdRef.current !== null) {
hoveredIdRef.current = null;
repaint();
}
};
const handleContainerClick = (event: React.MouseEvent<HTMLDivElement>) => {
// Clicks on the controls strip are not canvas clicks.
if (!onCanvas(event)) return;
const node = pickNodeAt(event.clientX, event.clientY);
if (node) handleNodeClick(node);
if (node) onSelect({ id: node.id, name: node.name, type: node.type });
else if (selectedId) onSelect(null);
};
const isEmpty = !loading && data.nodes.length === 0;
const nodeVisibility = useCallback(
(node: NodeObject<GraphNode>) => isVisible(node as GraphNode),
[isVisible],
);
const linkVisibility = useCallback(
(link: LinkObject<GraphNode, GraphEdge>) =>
isVisible(nodeById.get(endpointId(link.source as never))) &&
isVisible(nodeById.get(endpointId(link.target as never))),
[isVisible, nodeById],
);
const linkColor = useCallback(
(link: LinkObject<GraphNode, GraphEdge>) => {
if (!focus) return palette.link;
return linkTouches(link as GraphEdge, selectedId)
? palette.primary
: fadedLink;
},
[focus, selectedId, palette, fadedLink],
);
const linkWidth = useCallback(
(link: LinkObject<GraphNode, GraphEdge>) =>
focus && linkTouches(link as GraphEdge, selectedId) ? 1.5 : 1,
[focus, selectedId],
);
const nodeVal = useCallback(
(node: NodeObject<GraphNode>) =>
nodeRadius((node as GraphNode).degree, maxNodeDegree),
[maxNodeDegree],
);
const nodeCanvasObject = useCallback(
(
node: NodeObject<GraphNode>,
ctx: CanvasRenderingContext2D,
globalScale: number,
) => {
const graphNode = node as PositionedNode;
if (graphNode.x == null || graphNode.y == null) return;
const { x, y } = graphNode;
const r = nodeRadius(graphNode.degree, maxNodeDegree);
const hoveredId = hoveredIdRef.current;
const isSelected = graphNode.id === selectedId;
const series = fold.seriesOf(graphNode.type);
ctx.save();
ctx.globalAlpha = focus && !focus.has(graphNode.id) ? DIM_ALPHA : 1;
ctx.beginPath();
ctx.arc(x, y, r, 0, 2 * Math.PI);
ctx.fillStyle =
series == null
? palette.other
: (palette.series[series] ?? palette.other);
ctx.fill();
if (isSelected || graphNode.id === hoveredId) {
ctx.lineWidth = (isSelected ? 2.5 : 1.5) / globalScale;
ctx.strokeStyle = palette.hoverStroke;
ctx.stroke();
}
ctx.restore();
},
[maxNodeDegree, selectedId, fold, focus, palette],
);
// Labels go on top of every node (a per-node label would sit under the
// nodes drawn after it): one pass after the frame. In priority order (the
// selection, the hovered node, then by degree) a label that would sit on
// one already placed is skipped; the first two always draw.
const drawLabels = useCallback(
(ctx: CanvasRenderingContext2D, globalScale: number) => {
const hoveredId = hoveredIdRef.current;
const rank = (node: GraphNode) =>
node.id === selectedId ? 0 : node.id === hoveredId ? 1 : 2;
const labelled = (visibleNodes as PositionedNode[])
.filter(
(node) =>
node.x != null &&
node.y != null &&
nodeHasLabel(node.id, { hubs, focus, hoveredId }),
)
.sort(
(a, b) =>
rank(a) - rank(b) ||
(b.degree || 0) - (a.degree || 0) ||
a.id.localeCompare(b.id),
);
ctx.save();
ctx.font = `${LABEL_PX / globalScale}px ${palette.font}`;
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.lineWidth = 3 / globalScale;
ctx.lineJoin = 'round';
const boxes: (LabelBox & { label: string })[] = labelled.map((node) => {
const label = node.name ?? '';
return {
id: node.id,
label,
x: node.x as number,
y:
(node.y as number) +
nodeRadius(node.degree, maxNodeDegree) +
2 / globalScale,
width: ctx.measureText(label).width,
height: LABEL_PX / globalScale,
always: rank(node) < 2,
};
});
const shown = pickLabels(boxes, LABEL_GAP_PX / globalScale);
for (const box of boxes) {
if (!shown.has(box.id)) continue;
ctx.strokeStyle = palette.halo;
ctx.strokeText(box.label, box.x, box.y);
ctx.fillStyle = palette.label;
ctx.fillText(box.label, box.x, box.y);
}
ctx.restore();
},
[visibleNodes, hubs, focus, maxNodeDegree, palette, selectedId],
);
// Once a new layout settles: fit it, or, with a node selected, centre on
// that node (its position only exists now; the focus effect ran before).
const handleEngineStop = useCallback(() => {
if (fittedRef.current) return;
fittedRef.current = true;
const node = selectedId
? (nodeById.get(selectedId) as PositionedNode | undefined)
: undefined;
if (node && node.x != null && node.y != null) {
fgRef.current?.centerAt(node.x, node.y, FOCUS_MS);
} else {
fitView();
}
}, [fitView, selectedId, nodeById]);
// The graph reports zoom while it renders (a prop update can move the
// view), so the readout follows on the next frame, at most once a frame.
const zoomFrameRef = useRef<number | null>(null);
const handleZoom = useCallback(({ k }: { k: number }) => {
if (zoomFrameRef.current !== null)
cancelAnimationFrame(zoomFrameRef.current);
zoomFrameRef.current = requestAnimationFrame(() => {
zoomFrameRef.current = null;
setZoom(k);
});
}, []);
useEffect(
() => () => {
if (zoomFrameRef.current !== null)
cancelAnimationFrame(zoomFrameRef.current);
},
[],
);
const zoomBy = (factor: number) => {
const fg = fgRef.current;
if (fg) fg.zoom(fg.zoom() * factor, ZOOM_MS);
};
const legendValue = legendKeys.filter((key) => !hidden.has(key));
const otherLabels = fold.other.labels;
const toolbar = (
<div className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-3">
<GraphEntitySearch docId={docId} fold={fold} onPick={onSelect} />
{/* One phrase at one size: "Show top [50 | 100 | 250] by
connections"; the muted track holds only the group. */}
<div className="flex flex-wrap items-center gap-2">
<span id={showTopId} className="text-muted-foreground text-sm">
{t('settings.sources.graphrag.view.showTop')}
</span>
<div className="bg-muted shrink-0 rounded-full p-1">
<ToggleGroup
type="single"
size="xs"
className="flex-nowrap"
value={String(limit)}
onValueChange={(value) => value && onLimitChange(Number(value))}
aria-labelledby={showTopId}
>
{GRAPH_LIMITS.map((option) => (
<ToggleGroupItem key={option} value={String(option)}>
{option}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
<span className="text-muted-foreground text-sm">
{t('settings.sources.graphrag.view.byConnections')}
</span>
</div>
</div>
{legendKeys.length > 0 ? (
<div className="flex flex-wrap items-center gap-2">
<div className="bg-muted rounded-full p-1">
<ToggleGroup
type="multiple"
size="xs"
value={legendValue}
onValueChange={(values) =>
setHidden(
new Set(legendKeys.filter((key) => !values.includes(key))),
)
}
aria-label={t('settings.sources.graphrag.view.typeFilter')}
>
{fold.groups.map((group) => (
<ToggleGroupItem key={group.key} value={group.key}>
<GraphSeriesDot series={group.series} />
{group.label}
<span className="text-muted-foreground tabular-nums">
{formatCount(group.count)}
</span>
</ToggleGroupItem>
))}
{fold.other.count > 0 ? (
<ToggleGroupItem value={OTHER_GROUP_KEY}>
<GraphSeriesDot series={null} />
{t('settings.analytics.otherSeries')}
<span className="text-muted-foreground tabular-nums">
{formatCount(fold.other.count)}
</span>
</ToggleGroupItem>
) : null}
</ToggleGroup>
</div>
{otherLabels.length > 0 ? (
<span className="text-muted-foreground text-xs">
{t('settings.sources.graphrag.view.otherTypes', {
types: otherTypesList(otherLabels, (count) =>
t('settings.sources.graphrag.view.otherTypesMore', {
count,
formatted: formatCount(count),
}),
),
interpolation: { escapeValue: false },
})}
</span>
) : null}
</div>
) : null}
</div>
);
if (status === 'error') {
return (
<EmptyState
size="sm"
tone="destructive"
illustration="none"
title={t('settings.sources.graphrag.view.loadFailed')}
action={
<Button
type="button"
variant="outline"
size="sm"
shape="pill"
onClick={onRetry}
>
{t('retry')}
</Button>
}
/>
);
}
if (status === 'ready' && data.nodes.length === 0) {
return (
<EmptyState
size="sm"
illustration="none"
title={t('settings.sources.graphrag.view.empty')}
/>
);
}
const panel = selected ? (
<GraphNodePanel
key={selected.id}
docId={docId}
node={selected}
detail={nodeDetail.detail}
status={nodeDetail.status}
onRetry={nodeDetail.retry}
fold={fold}
onClose={() => onSelect(null)}
showClose={isDesktop}
onSelectNode={onSelect}
overview={data}
onOpenInFiles={onOpenInFiles}
onChunkSaved={nodeDetail.reload}
/>
) : null;
return (
<div className="flex flex-col">
<div className="mb-4 flex items-center">
<IconButton
variant="outline"
size="icon-xs"
shape="pill"
className="mr-3"
onClick={onBackToDocuments}
label={t('settings.sources.backToAll')}
icon={ArrowLeft}
side="bottom"
/>
<span className="text-primary font-semibold wrap-break-word">
{sourceName}
</span>
{headerAction ? <div className="ml-auto">{headerAction}</div> : null}
</div>
<div className="bg-muted text-muted-foreground mb-4 flex items-start gap-2 rounded-xl px-4 py-3 text-xs">
<Network className="mt-0.5 size-4 shrink-0" aria-hidden="true" />
<p>
<span className="text-foreground font-medium">
{t('settings.sources.graphrag.view.title')}
</span>{' '}
{t('settings.sources.graphrag.view.explainer')}
</p>
</div>
{loading ? (
<SkeletonLoader count={4} />
) : isEmpty ? (
<div className="border-border text-muted-foreground flex flex-col items-center gap-2 rounded-xl border border-dashed px-6 py-12 text-center text-sm">
<Network className="size-7" aria-hidden="true" />
<p>{t('settings.sources.graphrag.view.empty')}</p>
</div>
) : (
<div className="flex flex-col gap-2">
<p className="text-muted-foreground text-xs">
{t('settings.sources.graphrag.view.stats', {
nodes: data.nodes.length,
edges: data.links.length,
})}
</p>
<div className="flex flex-col gap-4 lg:flex-row lg:items-start">
<div
ref={containerRef}
onMouseMove={handlePointerMove}
onMouseLeave={handlePointerLeave}
onClick={handleContainerClick}
className="border-border bg-card relative min-h-[480px] flex-1 overflow-hidden rounded-xl border"
>
{size.width > 0 && (
<ForceGraph2D
<div className="flex flex-col gap-4">
{toolbar}
<Card
variant="subtle"
padding="none"
className="h-[70svh] flex-row gap-0 overflow-hidden"
>
<div
ref={containerRef}
onMouseMove={handlePointerMove}
onMouseLeave={handlePointerLeave}
onClick={handleContainerClick}
className="relative min-w-0 flex-1"
>
{status === 'loading' ? (
<LoadingState fill="parent" />
) : (
<>
{hasCanvas && (
<ForceGraph2D<GraphNode, GraphEdge>
ref={fgRef}
graphData={data}
width={size.width}
height={size.height}
nodeRelSize={1}
minZoom={0.2}
maxZoom={8}
enablePointerInteraction={false}
enableNodeDrag={false}
nodeVal={(node) =>
nodeRadius((node as GraphNode).degree, maxNodeDegree)
}
linkColor={() => palette.link}
nodeVal={nodeVal}
nodeVisibility={nodeVisibility}
linkVisibility={linkVisibility}
linkColor={linkColor}
linkWidth={linkWidth}
cooldownTicks={80}
nodeCanvasObject={(node, ctx, globalScale) => {
const graphNode = node as GraphNode & {
x?: number;
y?: number;
};
if (graphNode.x == null) return;
const r = nodeRadius(graphNode.degree, maxNodeDegree);
const hovered = hoveredNodeIdRef.current === graphNode.id;
ctx.beginPath();
ctx.arc(graphNode.x, graphNode.y ?? 0, r, 0, 2 * Math.PI);
ctx.fillStyle = palette.node;
ctx.fill();
if (hovered) {
ctx.lineWidth = 2 / globalScale;
ctx.strokeStyle = palette.hoverStroke;
ctx.stroke();
}
if (globalScale >= 1.2) {
const label = graphNode.name ?? '';
const x = graphNode.x;
const y = (graphNode.y ?? 0) + r + 1;
ctx.font = `${10 / globalScale}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.lineWidth = 3 / globalScale;
ctx.lineJoin = 'round';
ctx.strokeStyle = palette.halo;
ctx.strokeText(label, x, y);
ctx.fillStyle = palette.label;
ctx.fillText(label, x, y);
}
}}
onEngineStop={handleEngineStop}
onZoom={handleZoom}
nodeCanvasObject={nodeCanvasObject}
onRenderFramePost={drawLabels}
/>
)}
</div>
<aside className="border-border bg-card flex max-h-[480px] w-full shrink-0 flex-col overflow-y-auto rounded-xl border p-4 lg:w-80">
{loadingNode ? (
<SkeletonLoader count={3} />
) : selectedNode ? (
<div className="flex flex-col">
<div className="mb-2 flex items-start justify-between gap-2">
<div className="min-w-0">
<SectionHeader
as="h3"
size="xs"
className="wrap-break-word"
title={selectedNode.name}
/>
{selectedNode.type && (
<Badge variant="neutral" className="mt-1">
{selectedNode.type}
</Badge>
)}
</div>
<IconButton
variant="ghost-muted"
size="icon-xs"
className="shrink-0"
onClick={() => setSelectedNode(null)}
label={t('settings.sources.graphrag.view.close')}
icon={X}
side="bottom"
/>
</div>
{selectedNode.description && (
<p className="text-muted-foreground mb-3 text-sm leading-relaxed wrap-break-word">
{selectedNode.description}
</p>
)}
<div className="flex flex-col gap-2">
<SectionHeader
as="h4"
size="xs"
title={t('settings.sources.graphrag.view.linkedChunks')}
/>
{selectedNode.chunks.length === 0 ? (
<p className="text-muted-foreground text-xs">
{t('settings.sources.graphrag.view.noChunks')}
</p>
) : (
<ul className="flex flex-col gap-2">
{selectedNode.chunks.map((chunk) => (
<li
key={chunk.chunk_id}
className="border-border text-muted-foreground rounded-md border px-3 py-2 text-xs leading-relaxed wrap-break-word"
>
{chunk.text}
</li>
))}
</ul>
)}
</div>
</div>
) : (
<p className="text-muted-foreground py-2 text-sm">
{!selected ? (
<p className="text-muted-foreground pointer-events-none absolute top-3 left-3 text-xs">
{t('settings.sources.graphrag.view.selectNode')}
</p>
)}
</aside>
</div>
) : null}
<GraphCanvasControls
zoom={zoom}
onZoomIn={() => zoomBy(ZOOM_STEP)}
onZoomOut={() => zoomBy(1 / ZOOM_STEP)}
onFit={() => fitView()}
/>
</>
)}
</div>
)}
{isDesktop && panel ? (
<GraphNodePanelDock>{panel}</GraphNodePanelDock>
) : null}
</Card>
{!isDesktop ? (
<Sheet
open={!!selected && active}
onOpenChange={(open) => {
if (!open) onSelect(null);
}}
>
<SheetContent
side="bottom"
handle
title={selected?.name}
// Open on the panel, not with a ring on its first control.
onOpenAutoFocus={(event) => event.preventDefault()}
>
{panel}
</SheetContent>
</Sheet>
) : null}
</div>
);
};
+2 -1
View File
@@ -1,7 +1,7 @@
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { markdownHeadings } from '@/lib/markdown';
import { markdownHeadings, markdownTables } from '@/lib/markdown';
import { Button } from './ui/button';
@@ -18,6 +18,7 @@ export default function MarkdownPreview({ content }: { content: string }) {
remarkPlugins={[remarkGfm]}
components={{
...markdownHeadings,
...markdownTables,
a({ children, href }) {
return (
<Button variant="link" size="inline" asChild>
Loaded 100 of 223 files, more files were not shown because too many files have changed in this diff. Show more