Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 60a2e9482d | |||
| 207844ffc2 | |||
| 34e102baca | |||
| 3904aeb447 | |||
| 424367d8b5 | |||
| 3dbd7d89d2 | |||
| 4860bc0dd2 | |||
| 4861e5251c | |||
| d77c155032 | |||
| 205d8ba303 | |||
| 1c899f8377 | |||
| f82584dd1e | |||
| ca709a3599 | |||
| ad95687d8c | |||
| a74985f33a | |||
| 39a06168b2 | |||
| d1a34f2fd5 | |||
| 2caeb1e646 | |||
| 9ed17b2e09 | |||
| 68fa897bde | |||
| d241bee3f9 | |||
| 18e12a29e3 | |||
| d5473fc0a9 | |||
| 913beb2485 |
@@ -65,11 +65,6 @@ class ApiError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class MergePermissionError(ApiError):
|
||||
"""Merge failed with a permanent permission error (403/404/405).
|
||||
The queue should skip this PR and move to the next one."""
|
||||
|
||||
|
||||
@dataclasses.dataclass(frozen=True)
|
||||
class MergeDecision:
|
||||
ready: bool
|
||||
@@ -153,38 +148,15 @@ def latest_statuses_by_context(statuses: list[dict]) -> dict[str, dict]:
|
||||
return latest
|
||||
|
||||
|
||||
def _is_tier_low_pending_ok(
|
||||
latest_statuses: dict[str, dict],
|
||||
context: str,
|
||||
pr_labels: set[str],
|
||||
) -> bool:
|
||||
"""Return True if tier:low PR can tolerate sop-checklist pending state.
|
||||
|
||||
Per sop-checklist-config.yaml tier_failure_mode, tier:low uses soft-fail:
|
||||
sop-checklist posts state=pending when acks are satisfied (missing
|
||||
manager/ceo acks are informational only). The queue should accept
|
||||
pending instead of waiting for success.
|
||||
"""
|
||||
if "tier:low" not in pr_labels:
|
||||
return False
|
||||
if "sop-checklist" not in context:
|
||||
return False
|
||||
status = latest_statuses.get(context) or {}
|
||||
return status_state(status) == "pending"
|
||||
|
||||
|
||||
def required_contexts_green(
|
||||
latest_statuses: dict[str, dict],
|
||||
contexts: list[str],
|
||||
pr_labels: set[str] | None = None,
|
||||
) -> tuple[bool, list[str]]:
|
||||
missing_or_bad: list[str] = []
|
||||
for context in contexts:
|
||||
status = latest_statuses.get(context)
|
||||
state = status_state(status or {})
|
||||
if state != "success":
|
||||
if pr_labels and _is_tier_low_pending_ok(latest_statuses, context, pr_labels):
|
||||
continue # tier:low soft-fail: accept pending sop-checklist
|
||||
missing_or_bad.append(f"{context}={state or 'missing'}")
|
||||
return not missing_or_bad, missing_or_bad
|
||||
|
||||
@@ -237,7 +209,6 @@ def evaluate_merge_readiness(
|
||||
pr_status: dict,
|
||||
required_contexts: list[str],
|
||||
pr_has_current_base: bool,
|
||||
pr_labels: set[str] | None = None,
|
||||
) -> MergeDecision:
|
||||
# Check push-required contexts explicitly instead of combined state.
|
||||
# Combined state can be "failure" due to non-blocking jobs
|
||||
@@ -257,7 +228,7 @@ def evaluate_merge_readiness(
|
||||
# The required_contexts list is the authoritative gate — it includes only
|
||||
# the checks that actually block merges.
|
||||
latest = latest_statuses_by_context(pr_status.get("statuses") or [])
|
||||
ok, missing_or_bad = required_contexts_green(latest, required_contexts, pr_labels)
|
||||
ok, missing_or_bad = required_contexts_green(latest, required_contexts)
|
||||
if not ok:
|
||||
return MergeDecision(False, "wait", "required contexts not green: " + ", ".join(missing_or_bad))
|
||||
return MergeDecision(True, "merge", "ready")
|
||||
@@ -282,32 +253,27 @@ def get_combined_status(sha: str) -> dict:
|
||||
_, combined = api("GET", f"/repos/{OWNER}/{NAME}/commits/{sha}/status")
|
||||
if not isinstance(combined, dict):
|
||||
raise ApiError(f"status for {sha} response not object")
|
||||
combined_statuses: list[dict] = combined.get("statuses") or []
|
||||
# Fetch full statuses list; 200 covers >99% of real-world runs.
|
||||
# The list is ordered ascending by id (oldest first) — callers must
|
||||
# iterate in reverse to get the newest entry per context.
|
||||
# Best-effort: large repos (main with 550+ statuses) may time out.
|
||||
# On timeout, fall back to the statuses[] already in the combined
|
||||
# response (usually 30 entries — enough for most PRs, enough for
|
||||
# main's early push-required contexts).
|
||||
try:
|
||||
_, all_statuses_raw = api(
|
||||
_, all_statuses = api(
|
||||
"GET",
|
||||
f"/repos/{OWNER}/{NAME}/commits/{sha}/statuses",
|
||||
query={"limit": "50"},
|
||||
)
|
||||
if isinstance(all_statuses_raw, list):
|
||||
all_statuses: list[dict] = list(all_statuses_raw)
|
||||
else:
|
||||
all_statuses = []
|
||||
if isinstance(all_statuses, list):
|
||||
combined["statuses"] = all_statuses
|
||||
except (ApiError, urllib.error.URLError, TimeoutError, OSError) as exc:
|
||||
# URLError covers network-level failures (DNS, refused, timeout).
|
||||
# TimeoutError and OSError cover socket-level timeouts.
|
||||
sys.stderr.write(f"::warning::could not fetch full statuses list for {sha[:8]}: {exc}\n")
|
||||
all_statuses = []
|
||||
# Build latest per context: process combined (ascending→reverse=newest
|
||||
# first), then fill gaps from all_statuses (already newest-first).
|
||||
latest: dict[str, dict] = {}
|
||||
for status in reversed(sorted(combined_statuses, key=lambda s: s.get("id") or 0)):
|
||||
ctx = status.get("context")
|
||||
if isinstance(ctx, str) and ctx not in latest:
|
||||
latest[ctx] = status
|
||||
for status in all_statuses:
|
||||
ctx = status.get("context")
|
||||
if isinstance(ctx, str) and ctx not in latest:
|
||||
latest[ctx] = status
|
||||
combined["statuses"] = list(latest.values())
|
||||
# Fall back to the statuses[] already in the combined response.
|
||||
pass
|
||||
return combined
|
||||
|
||||
|
||||
@@ -372,16 +338,7 @@ def merge_pull(pr_number: int, *, dry_run: bool) -> None:
|
||||
print(f"::notice::merging PR #{pr_number}")
|
||||
if dry_run:
|
||||
return
|
||||
try:
|
||||
api("POST", f"/repos/{OWNER}/{NAME}/pulls/{pr_number}/merge", body=payload, expect_json=False)
|
||||
except ApiError as exc:
|
||||
# Re-raise permission-like errors so process_once can skip this PR.
|
||||
# 403 = no push access, 404 = repo/pr not found, 405 = not allowed.
|
||||
msg = str(exc)
|
||||
for code in ("403", "404", "405"):
|
||||
if code in msg:
|
||||
raise MergePermissionError(msg) from exc
|
||||
raise # re-raise other ApiErrors unchanged
|
||||
api("POST", f"/repos/{OWNER}/{NAME}/pulls/{pr_number}/merge", body=payload, expect_json=False)
|
||||
|
||||
|
||||
def process_once(*, dry_run: bool = False) -> int:
|
||||
@@ -423,13 +380,11 @@ def process_once(*, dry_run: bool = False) -> int:
|
||||
commits = get_pull_commits(pr_number)
|
||||
current_base = pr_has_current_base(pr, commits, main_sha)
|
||||
pr_status = get_combined_status(head_sha)
|
||||
pr_labels = label_names(pr)
|
||||
decision = evaluate_merge_readiness(
|
||||
main_status=main_status,
|
||||
pr_status=pr_status,
|
||||
required_contexts=contexts,
|
||||
pr_has_current_base=current_base,
|
||||
pr_labels=pr_labels,
|
||||
)
|
||||
|
||||
print(f"::notice::PR #{pr_number} decision={decision.action}: {decision.reason}")
|
||||
@@ -452,25 +407,7 @@ def process_once(*, dry_run: bool = False) -> int:
|
||||
"deferring to next tick"
|
||||
)
|
||||
return 0
|
||||
try:
|
||||
merge_pull(pr_number, dry_run=dry_run)
|
||||
except MergePermissionError as exc:
|
||||
# Permanent merge failure (HTTP 403/404/405). Post a comment so
|
||||
# maintainers know why, then return 0 so this tick is done.
|
||||
# The PR stays in the queue; future ticks can retry after the
|
||||
# permission issue is resolved.
|
||||
sys.stderr.write(f"::error::merge permission error for PR #{pr_number}: {exc}\n")
|
||||
post_comment(
|
||||
pr_number,
|
||||
(
|
||||
"merge-queue: merge failed with HTTP 405 'User not allowed to merge PR'. "
|
||||
"No available token has Can-merge permission on this repo. "
|
||||
"Fix: grant Can-merge to a token, or add a maintain/admin collaborator. "
|
||||
"Skipping to next queued PR on next tick."
|
||||
),
|
||||
dry_run=dry_run,
|
||||
)
|
||||
return 0
|
||||
merge_pull(pr_number, dry_run=dry_run)
|
||||
return 0
|
||||
return 0
|
||||
|
||||
|
||||
@@ -100,12 +100,11 @@ printf 'header = "Authorization: token %s"\n' "$GITEA_TOKEN" > "$CURL_AUTH_FILE"
|
||||
# (bash trap 'function' EXIT expands variables at trap-fire time, not def time).
|
||||
PR_JSON=$(mktemp)
|
||||
REVIEWS_JSON=$(mktemp)
|
||||
COMMENTS_JSON=$(mktemp)
|
||||
TEAM_PROBE_TMP=$(mktemp)
|
||||
NA_STATUSES_TMP="" # declared here so cleanup() always has the var
|
||||
|
||||
cleanup() {
|
||||
rm -f "$CURL_AUTH_FILE" "$PR_JSON" "$REVIEWS_JSON" "$COMMENTS_JSON" "$TEAM_PROBE_TMP" "${NA_STATUSES_TMP-}"
|
||||
rm -f "$CURL_AUTH_FILE" "$PR_JSON" "$REVIEWS_JSON" "$TEAM_PROBE_TMP" "${NA_STATUSES_TMP-}"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
@@ -207,81 +206,7 @@ CANDIDATES=$(jq -r --arg author "$PR_AUTHOR" --arg head "$PR_HEAD_SHA" "$JQ_FILT
|
||||
debug "candidate non-author approvers: $(echo "$CANDIDATES" | tr '\n' ' ')"
|
||||
|
||||
if [ -z "$CANDIDATES" ]; then
|
||||
# --- Guardrail (internal#503): explain the most common false
|
||||
# "no candidates" red. Gitea's review event enum is EXACTLY
|
||||
# APPROVED/REQUEST_CHANGES/COMMENT/PENDING. A wrong value ("APPROVE",
|
||||
# lowercase, ...) is silently accepted (HTTP 200) and stored as
|
||||
# state=PENDING. A correctly-started draft review has an EMPTY body;
|
||||
# a NON-empty body + state==PENDING by a non-author == an intended
|
||||
# verdict mis-filed by a wrong event string. Surface it actionably.
|
||||
# This does NOT change the gate result (still fail-closed below) — it
|
||||
# only converts a mystery red into a named, self-fixing error.
|
||||
MISFILED_FILTER='.[]
|
||||
| select(.state == "PENDING")
|
||||
| select(.dismissed != true)
|
||||
| select(.user.login != $author)
|
||||
| select(((.body // "") | gsub("^\\s+|\\s+$";"") | length) > 0)
|
||||
| "\(.id)\t\(.user.login)"'
|
||||
MISFILED=$(jq -r --arg author "$PR_AUTHOR" "$MISFILED_FILTER" "$REVIEWS_JSON" 2>/dev/null || true)
|
||||
if [ -n "$MISFILED" ]; then
|
||||
echo "::error::${TEAM}-review: non-author review(s) were SUBMITTED but stored as PENDING — almost certainly the wrong Gitea review event string (internal#503)."
|
||||
echo "::error::Gitea accepts ONLY the exact enum APPROVED / REQUEST_CHANGES / COMMENT. 'APPROVE' or lowercase is silently (HTTP 200) filed as PENDING and is invisible to this gate."
|
||||
printf '%s\n' "$MISFILED" | while IFS="$(printf '\t')" read -r _rid _rl; do
|
||||
[ -n "${_rid:-}" ] && echo "::error:: review id=${_rid} by '${_rl}': RE-SUBMIT via POST ${API}/repos/${OWNER}/${NAME}/pulls/${PR_NUMBER}/reviews with {\"event\":\"APPROVED\"} (correct enum) — do NOT edit the DB."
|
||||
done
|
||||
fi
|
||||
|
||||
# --- Fallback (internal#348): check issue comments for agent-approval ---
|
||||
# core-qa-agent and core-security-agent approve via issue comments, NOT
|
||||
# the reviews API. The reviews API returns zero entries for comment-only
|
||||
# approvals. This fallback reads PR issue comments and extracts logins that:
|
||||
# 1. Posted a comment matching the agent-prefix pattern for this gate:
|
||||
# qa → "[core-qa-agent] APPROVED"
|
||||
# security → "[core-security-agent] APPROVED"
|
||||
# OR posted a generic approval keyword (word-anchored, case-insensitive):
|
||||
# APPROVED / LGTM / ACCEPTED
|
||||
# 2. Are not the PR author
|
||||
# 3. The team-membership probe below is the authoritative filter.
|
||||
AGENT_PATTERN=""
|
||||
case "$TEAM" in
|
||||
qa) AGENT_PATTERN="\\[core-qa-agent\\]" ;;
|
||||
security) AGENT_PATTERN="\\[core-security-agent\\]" ;;
|
||||
esac
|
||||
HTTP_CODE=$(curl -sS -o "$COMMENTS_JSON" -w '%{http_code}' \
|
||||
-K "$CURL_AUTH_FILE" "${API}/repos/${OWNER}/${NAME}/issues/${PR_NUMBER}/comments")
|
||||
debug "GET /issues/${PR_NUMBER}/comments → HTTP ${HTTP_CODE}"
|
||||
if [ "$HTTP_CODE" = "200" ]; then
|
||||
# JQ expression: select non-author comments that match either the
|
||||
# agent-prefix pattern (case-insensitive) OR a generic approval keyword.
|
||||
JQ_APPROVALS='
|
||||
.[] |
|
||||
select(.user.login != $author) |
|
||||
. as $cmt |
|
||||
if ($agent_pattern | length) > 0 and ($cmt.body // "" | test($agent_pattern; "i")) then
|
||||
$cmt.user.login
|
||||
elif ($cmt.body // "" | test("\\b(APPROVED|LGTM|ACCEPTED)\\b"; "i")) then
|
||||
$cmt.user.login
|
||||
else
|
||||
empty
|
||||
end
|
||||
'
|
||||
CANDIDATES=$(jq -r \
|
||||
--arg author "$PR_AUTHOR" \
|
||||
--arg agent_pattern "$AGENT_PATTERN" \
|
||||
"$JQ_APPROVALS" \
|
||||
"$COMMENTS_JSON" 2>/dev/null | sort -u)
|
||||
debug "comment-based approval candidates: $(echo "$CANDIDATES" | tr '\n' ' ')"
|
||||
|
||||
if [ -n "$CANDIDATES" ]; then
|
||||
echo "::notice::${TEAM}-review: reviews API found no APPROVED reviews; found $(echo "$CANDIDATES" | wc -w | xargs) comment-based approval candidate(s) — verifying team membership..."
|
||||
fi
|
||||
else
|
||||
debug "could not fetch issue comments (HTTP ${HTTP_CODE})"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -z "${CANDIDATES:-}" ]; then
|
||||
echo "::error::${TEAM}-review awaiting non-author APPROVE from ${TEAM} team (no candidates from reviews API or issue comments)"
|
||||
echo "::error::${TEAM}-review awaiting non-author APPROVE from ${TEAM} team (no candidates yet)"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
+25
-168
@@ -68,7 +68,7 @@ import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from typing import Any, Callable
|
||||
from typing import Any
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -110,7 +110,7 @@ def normalize_slug(raw: str, numeric_aliases: dict[int, str] | None = None) -> s
|
||||
# for /sop-revoke (RFC#351 open question 4 — reason is captured but not
|
||||
# yet validated; future iteration may require a min-length).
|
||||
_DIRECTIVE_RE = re.compile(
|
||||
r"^[ \t]*/(sop-ack|sop-revoke|sop-n/a)[ \t]+([A-Za-z0-9_\- ]+?)(?:[ \t]+(.*))?[ \t]*$",
|
||||
r"^[ \t]*/(sop-ack|sop-revoke)[ \t]+([A-Za-z0-9_\- ]+?)(?:[ \t]+(.*))?[ \t]*$",
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
@@ -118,21 +118,19 @@ _DIRECTIVE_RE = re.compile(
|
||||
def parse_directives(
|
||||
comment_body: str,
|
||||
numeric_aliases: dict[int, str],
|
||||
) -> tuple[list[tuple[str, str, str]], list[tuple[str, str, str]]]:
|
||||
"""Extract /sop-ack, /sop-revoke, and /sop-n/a directives from a comment body.
|
||||
) -> tuple[list[tuple[str, str, str]], list]:
|
||||
"""Extract /sop-ack and /sop-revoke directives from a comment body.
|
||||
|
||||
Returns (directives, na_directives) where each is a list of
|
||||
(kind, canonical_slug, note) tuples:
|
||||
kind is "sop-ack", "sop-revoke", or "sop-n/a"
|
||||
canonical_slug is the normalized form (or "" if unparseable)
|
||||
note is the trailing free-text (may be "")
|
||||
The two lists are kept separate so call sites can unpack them
|
||||
directly (e.g. directives, na_directives = parse_directives(...)).
|
||||
Returns (directives, na_directives) where:
|
||||
directives is a list of (kind, canonical_slug, note) tuples
|
||||
kind is "sop-ack" or "sop-revoke"
|
||||
canonical_slug is the normalized form (or "" if unparseable)
|
||||
note is the trailing free-text (may be "")
|
||||
na_directives is reserved for future N/A handling (always [] for now)
|
||||
"""
|
||||
directives: list[tuple[str, str, str]] = []
|
||||
na_directives: list[tuple[str, str, str]] = []
|
||||
out: list[tuple[str, str, str]] = []
|
||||
if not comment_body:
|
||||
return directives, na_directives
|
||||
return out, []
|
||||
for m in _DIRECTIVE_RE.finditer(comment_body):
|
||||
kind = m.group(1)
|
||||
raw_slug = (m.group(2) or "").strip()
|
||||
@@ -162,12 +160,8 @@ def parse_directives(
|
||||
note_from_group = (m.group(3) or "").strip()
|
||||
# If we collapsed multi-word slug into kebab and there's a
|
||||
# trailing-text group too, append it.
|
||||
entry = (kind, canonical, note_from_group)
|
||||
if kind == "sop-n/a":
|
||||
na_directives.append(entry)
|
||||
else:
|
||||
directives.append(entry)
|
||||
return directives, na_directives
|
||||
out.append((kind, canonical, note_from_group))
|
||||
return out, []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -180,8 +174,8 @@ def section_marker_present(body: str, marker: str) -> bool:
|
||||
on a non-empty line (i.e. the author actually filled it in).
|
||||
|
||||
We require the marker substring AND non-whitespace content on the
|
||||
same line OR within the next non-blank line — this prevents
|
||||
trivially-empty checklists like:
|
||||
same line OR within the next line — this prevents trivially-empty
|
||||
checklists like:
|
||||
|
||||
## SOP-Checklist
|
||||
- [ ] **Comprehensive testing performed**:
|
||||
@@ -190,18 +184,9 @@ def section_marker_present(body: str, marker: str) -> bool:
|
||||
from auto-passing the section-present check. The peer-ack is still
|
||||
required, but answering with empty content is captured as a soft
|
||||
finding via the section-present test alone.
|
||||
|
||||
NOTE: we scan forward through blank lines (the markdown-header pattern
|
||||
is ## Header\\n\\ncontent) so that a header + blank-line + content
|
||||
structure still satisfies the check. The backward checkbox fallback
|
||||
catches inline markers without a preceding checkbox (mc#1099).
|
||||
"""
|
||||
if not body or not marker:
|
||||
return False
|
||||
# Strip trailing whitespace so the blank-line scan below can find
|
||||
# content that appears on the very last line of the body (without
|
||||
# being misled by a trailing \n or spaces).
|
||||
body = body.rstrip()
|
||||
body_lower = body.lower()
|
||||
marker_lower = marker.lower()
|
||||
idx = body_lower.find(marker_lower)
|
||||
@@ -217,44 +202,13 @@ def section_marker_present(body: str, marker: str) -> bool:
|
||||
stripped = re.sub(r"[\s\*:\-\[\]]+", "", line)
|
||||
if stripped:
|
||||
return True
|
||||
# Fall through: scan forward, skipping blank-only lines, until we find
|
||||
# non-empty content or run out of body. Handles:
|
||||
# ## Header ← marker line (empty after marker)
|
||||
# ← blank line (skipped)
|
||||
# - actual content ← found
|
||||
pos = line_end
|
||||
while True:
|
||||
# Skip the current newline and any additional newlines (blank lines).
|
||||
while pos < len(body) and body[pos] == "\n":
|
||||
pos += 1
|
||||
if pos >= len(body):
|
||||
break
|
||||
line_end = body.find("\n", pos)
|
||||
if line_end < 0:
|
||||
line_end = len(body)
|
||||
line = body[pos:line_end]
|
||||
stripped = re.sub(r"[\s\*:\-\[\]]+", "", line)
|
||||
if stripped:
|
||||
return True
|
||||
pos = line_end
|
||||
# Last resort: the marker may appear mid-sentence (e.g.
|
||||
# **Memory/saved-feedback consulted**: No applicable...).
|
||||
# Search backward within the CURRENT LINE only (not preceding lines)
|
||||
# to find a checkbox on the same line before the marker text.
|
||||
# mc#1099 follow-up: memory-consulted detection was failing because
|
||||
# the checkbox was on the same line before the inline marker.
|
||||
_CHECKBOX_RE = re.compile(r"- \[[ x\]]|<input", re.IGNORECASE)
|
||||
line_start = body.rfind("\n", 0, idx) + 1 # 0 if no newline before idx
|
||||
before = body[line_start:idx]
|
||||
m = _CHECKBOX_RE.search(before)
|
||||
if not m:
|
||||
return False
|
||||
# Require meaningful content between the checkbox and the marker text
|
||||
# (markdown formatting like ** or * must also be stripped).
|
||||
# If only whitespace/markdown chars remain, the checkbox line is empty.
|
||||
between = before[m.end() :]
|
||||
stripped_between = re.sub(r"[\s\*:#\[\]_\-]+", "", between)
|
||||
return bool(stripped_between)
|
||||
# Fall through: check the NEXT line (multi-line answers).
|
||||
next_line_end = body.find("\n", line_end + 1)
|
||||
if next_line_end < 0:
|
||||
next_line_end = len(body)
|
||||
next_line = body[line_end + 1:next_line_end]
|
||||
stripped_next = re.sub(r"[\s\*:\-\[\]]+", "", next_line)
|
||||
return bool(stripped_next)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -297,7 +251,8 @@ def compute_ack_state(
|
||||
user = (c.get("user") or {}).get("login", "")
|
||||
if not user:
|
||||
continue
|
||||
for kind, slug, _note in parse_directives(body, numeric_aliases)[0]:
|
||||
directives, _na = parse_directives(body, numeric_aliases)
|
||||
for kind, slug, _note in directives:
|
||||
if not slug:
|
||||
unparseable_per_user[user] = unparseable_per_user.get(user, 0) + 1
|
||||
continue
|
||||
@@ -349,63 +304,6 @@ def compute_ack_state(
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# N/A-gate evaluation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def compute_na_state(
|
||||
comments: list[dict[str, Any]],
|
||||
author: str,
|
||||
na_gates: dict[str, Any],
|
||||
probe: Callable[[str, list[str]], list[str]],
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""Evaluate which N/A gates have a valid declaration from a team member.
|
||||
|
||||
Returns dict[gate_name, dict] where each dict has:
|
||||
declared: bool — at least one valid non-author team-member declared N/A
|
||||
decl_ackers: list[str] — usernames who declared this gate N/A
|
||||
rejected: dict with keys:
|
||||
not_in_team: list[str] — users who tried but aren't in required teams
|
||||
"""
|
||||
# Build per-user latest N/A directive (most-recent wins per RFC#324).
|
||||
latest_na: dict[str, tuple[str, str]] = {} # user → (gate, note)
|
||||
for c in comments:
|
||||
body = c.get("body", "") or ""
|
||||
user = (c.get("user") or {}).get("login", "")
|
||||
if not user:
|
||||
continue
|
||||
for kind, gate, note in parse_directives(body, {})[1]:
|
||||
# [1] = na_directives only
|
||||
if gate in na_gates:
|
||||
latest_na[user] = (gate, note)
|
||||
|
||||
result: dict[str, dict[str, Any]] = {}
|
||||
for gate, gate_cfg in na_gates.items():
|
||||
result[gate] = {
|
||||
"declared": False,
|
||||
"decl_ackers": [],
|
||||
"rejected": {"not_in_team": []},
|
||||
}
|
||||
decl_ackers: list[str] = []
|
||||
not_in_team: list[str] = []
|
||||
for user, (g, _note) in latest_na.items():
|
||||
if g != gate:
|
||||
continue
|
||||
if user == author:
|
||||
continue # authors cannot self-declare N/A
|
||||
approved = probe(gate, [user])
|
||||
if approved:
|
||||
decl_ackers.append(user)
|
||||
else:
|
||||
not_in_team.append(user)
|
||||
result[gate]["declared"] = bool(decl_ackers)
|
||||
result[gate]["decl_ackers"] = decl_ackers
|
||||
result[gate]["rejected"]["not_in_team"] = not_in_team
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gitea API client
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -800,7 +698,6 @@ def main(argv: list[str] | None = None) -> int:
|
||||
cfg = load_config(args.config)
|
||||
items: list[dict[str, Any]] = cfg["items"]
|
||||
items_by_slug = {it["slug"]: it for it in items}
|
||||
na_gates: dict[str, Any] = cfg.get("n/a_gates", {})
|
||||
numeric_aliases = {
|
||||
int(it["numeric_alias"]): it["slug"] for it in items if it.get("numeric_alias")
|
||||
}
|
||||
@@ -921,46 +818,6 @@ def main(argv: list[str] | None = None) -> int:
|
||||
description=description, target_url=target_url,
|
||||
)
|
||||
print(f"::notice::status posted: {args.status_context} → {state}")
|
||||
|
||||
# --- N/A gate status (RFC#324 §N/A follow-up) ---
|
||||
# Post a separate status so review-check.sh can discover N/A declarations
|
||||
# and waive the Gitea-approve requirement for that gate.
|
||||
na_state: dict[str, dict[str, Any]] = {}
|
||||
if na_gates:
|
||||
na_state = compute_na_state(comments, author, na_gates, probe)
|
||||
|
||||
na_descs: list[str] = []
|
||||
for gate, s in na_state.items():
|
||||
if s["declared"]:
|
||||
na_descs.append(gate)
|
||||
decl = s["decl_ackers"]
|
||||
rej = s["rejected"]["not_in_team"]
|
||||
if decl:
|
||||
print(f"::notice:: [N/A OK] {gate} — declared by {','.join(decl)}")
|
||||
if rej:
|
||||
print(
|
||||
f"::notice:: [N/A REJ] {gate} — not-in-team: {','.join(rej)}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
na_desc = ", ".join(sorted(na_descs)) if na_descs else "(none)"
|
||||
na_status_state = "success" if na_descs else "pending"
|
||||
# review-check.sh reads the description to discover which gates are N/A.
|
||||
# Include the gate names so it can grep for them.
|
||||
na_description = f"N/A: {na_desc}" if na_descs else "N/A: (none)"
|
||||
|
||||
if not args.dry_run:
|
||||
client.post_status(
|
||||
args.owner, args.repo, head_sha,
|
||||
state=na_status_state,
|
||||
context="sop-checklist / na-declarations (pull_request)",
|
||||
description=na_description,
|
||||
target_url=target_url,
|
||||
)
|
||||
print(
|
||||
f"::notice::na-declarations status → {na_status_state}: {na_description}"
|
||||
)
|
||||
|
||||
# By default exit 0 — the POSTed status IS the gate, NOT the job
|
||||
# conclusion. If the job exits 1 BP will see TWO failure signals
|
||||
# (one from the job's auto-status, one from our POST), making the
|
||||
|
||||
@@ -17,9 +17,6 @@ Scenarios:
|
||||
T8_team_not_member — team membership → 404 (not a member) → exit 1
|
||||
T9_team_403 — team membership → 403 (token not in team) → exit 1
|
||||
T14_non_default_base — open PR targeting staging → script exits 0 (no-op)
|
||||
T15_comments_agent_approval — reviews empty; comments have "[core-qa-agent] APPROVED" → exit 0
|
||||
T16_comments_generic_approval — reviews empty; comments have "APPROVED" by team member → exit 0
|
||||
T17_comments_no_approval — reviews empty; comments have no approval keywords → exit 1
|
||||
|
||||
Usage:
|
||||
FIXTURE_STATE_DIR=/tmp/x python3 _review_check_fixture.py 8080
|
||||
@@ -100,9 +97,7 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
# GET /repos/{owner}/{name}/pulls/{pr_number}/reviews
|
||||
m = re.match(r"^/api/v1/repos/([^/]+)/([^/]+)/pulls/(\d+)/reviews$", path)
|
||||
if m:
|
||||
if sc in ("T4_reviews_empty", "T5_reviews_only_author",
|
||||
"T15_comments_agent_approval", "T16_comments_generic_approval",
|
||||
"T17_comments_no_approval"):
|
||||
if sc in ("T4_reviews_empty", "T5_reviews_only_author"):
|
||||
return self._json(200, [])
|
||||
if sc == "T6_reviews_dismissed":
|
||||
return self._json(200, [{
|
||||
@@ -121,28 +116,6 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
{"state": "APPROVED", "dismissed": False, "user": {"login": "core-devops"}, "commit_id": "abc1234"},
|
||||
])
|
||||
|
||||
# GET /repos/{owner}/{name}/issues/{pr_number}/comments
|
||||
m = re.match(r"^/api/v1/repos/([^/]+)/([^/]+)/issues/(\d+)/comments$", path)
|
||||
if m:
|
||||
if sc == "T15_comments_agent_approval":
|
||||
return self._json(200, [
|
||||
{"user": {"login": "core-qa-agent"}, "body": "[core-qa-agent] APPROVED this PR. Good changes.", "id": 1},
|
||||
{"user": {"login": "alice"}, "body": "I authored this PR", "id": 2},
|
||||
{"user": {"login": "random-user"}, "body": "Looks okay to me", "id": 3},
|
||||
])
|
||||
if sc == "T16_comments_generic_approval":
|
||||
return self._json(200, [
|
||||
{"user": {"login": "core-qa-agent"}, "body": "APPROVED — all acceptance criteria met", "id": 1},
|
||||
{"user": {"login": "alice"}, "body": "-authored", "id": 2},
|
||||
])
|
||||
if sc == "T17_comments_no_approval":
|
||||
return self._json(200, [
|
||||
{"user": {"login": "alice"}, "body": "I authored this PR", "id": 1},
|
||||
{"user": {"login": "random-user"}, "body": "Looks okay to me", "id": 2},
|
||||
])
|
||||
# Default scenarios (T1–T9, T14): no comments
|
||||
return self._json(200, [])
|
||||
|
||||
# GET /teams/{team_id}/members/{username}
|
||||
m = re.match(r"^/api/v1/teams/(\d+)/members/([^/]+)$", path)
|
||||
if m:
|
||||
@@ -154,12 +127,6 @@ class Handler(http.server.BaseHTTPRequestHandler):
|
||||
# T7_team_member: member
|
||||
return self._empty(204)
|
||||
|
||||
# GET /repos/{owner}/{name}/statuses/{sha} — for N/A declaration check
|
||||
m = re.match(r"^/api/v1/repos/([^/]+)/([^/]+)/statuses/([a-f0-9]+)$", path)
|
||||
if m:
|
||||
# All comment-based scenarios have no N/A declarations
|
||||
return self._json(200, [])
|
||||
|
||||
return self._json(404, {"path": path, "msg": "fixture: no route"})
|
||||
|
||||
def do_POST(self):
|
||||
|
||||
@@ -118,13 +118,3 @@ def test_merge_decision_updates_stale_pr_before_merge():
|
||||
|
||||
assert decision.ready is False
|
||||
assert decision.action == "update"
|
||||
|
||||
|
||||
def test_MergePermissionError_inherits_from_ApiError():
|
||||
assert issubclass(mq.MergePermissionError, mq.ApiError)
|
||||
|
||||
|
||||
def test_MergePermissionError_message_preserved():
|
||||
exc = mq.MergePermissionError("POST /merge -> HTTP 405: User not allowed")
|
||||
assert "405" in str(exc)
|
||||
assert "User not allowed" in str(exc)
|
||||
|
||||
@@ -334,31 +334,6 @@ assert_contains "T12 jq: core-devops (non-author APPROVED) in candidates" "core-
|
||||
assert_eq "T12 jq: alice (author) NOT in candidates" "" "$(echo "$T12_CANDIDATES" | grep '^alice$' || true)"
|
||||
assert_eq "T12 jq: carol (dismissed) NOT in candidates" "" "$(echo "$T12_CANDIDATES" | grep '^carol$' || true)"
|
||||
|
||||
# T15 — comment-based approval via agent prefix pattern → exit 0
|
||||
echo
|
||||
echo "== T15 comment agent-prefix approval =="
|
||||
T15_OUT=$(run_review_check "T15_comments_agent_approval")
|
||||
T15_RC=$(cat "$FIX_STATE_DIR/last_rc")
|
||||
assert_eq "T15 exit code 0 (agent-comment approval + team member)" "0" "$T15_RC"
|
||||
assert_contains "T15 comment fallback notice" "comment-based approval" "$T15_OUT"
|
||||
assert_contains "T15 core-qa-agent APPROVED" "APPROVED by core-qa-agent" "$T15_OUT"
|
||||
|
||||
# T16 — comment-based approval via generic APPROVED keyword → exit 0
|
||||
echo
|
||||
echo "== T16 comment generic keyword approval =="
|
||||
T16_OUT=$(run_review_check "T16_comments_generic_approval")
|
||||
T16_RC=$(cat "$FIX_STATE_DIR/last_rc")
|
||||
assert_eq "T16 exit code 0 (generic-approval comment + team member)" "0" "$T16_RC"
|
||||
assert_contains "T16 comment fallback notice" "comment-based approval" "$T16_OUT"
|
||||
|
||||
# T17 — no approval keywords in comments → exit 1
|
||||
echo
|
||||
echo "== T17 comments with no approval keywords =="
|
||||
T17_OUT=$(run_review_check "T17_comments_no_approval")
|
||||
T17_RC=$(cat "$FIX_STATE_DIR/last_rc")
|
||||
assert_eq "T17 exit code 1 (no candidates from comments)" "1" "$T17_RC"
|
||||
assert_contains "T17 no candidates error" "no candidates from reviews API or issue comments" "$T17_OUT"
|
||||
|
||||
echo
|
||||
echo "------"
|
||||
echo "PASS=$PASS FAIL=$FAIL"
|
||||
|
||||
@@ -551,55 +551,3 @@ class TestEndToEndAckFlow(unittest.TestCase):
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main(verbosity=2)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# compute_na_state
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestComputeNaState(unittest.TestCase):
|
||||
"""Tests for /sop-n/a directive evaluation."""
|
||||
|
||||
def test_no_na_declarations(self):
|
||||
cfg = sop.load_config(CONFIG_PATH)
|
||||
na_gates = cfg.get("n/a_gates", {})
|
||||
comments = []
|
||||
na_state = sop.compute_na_state(comments, "alice", na_gates, lambda *_: [])
|
||||
self.assertFalse(na_state["qa-review"]["declared"])
|
||||
self.assertFalse(na_state["security-review"]["declared"])
|
||||
|
||||
def test_na_declared_by_authorized_user(self):
|
||||
cfg = sop.load_config(CONFIG_PATH)
|
||||
na_gates = cfg.get("n/a_gates", {})
|
||||
comments = [_comment("bob", "/sop-n/a qa-review N/A: pure tooling change")]
|
||||
na_state = sop.compute_na_state(comments, "alice", na_gates, lambda g, u: u)
|
||||
self.assertTrue(na_state["qa-review"]["declared"])
|
||||
self.assertEqual(na_state["qa-review"]["decl_ackers"], ["bob"])
|
||||
|
||||
def test_na_declared_by_unauthorized_user_rejected(self):
|
||||
cfg = sop.load_config(CONFIG_PATH)
|
||||
na_gates = cfg.get("n/a_gates", {})
|
||||
comments = [_comment("mallory", "/sop-n/a qa-review N/A: not real team")]
|
||||
na_state = sop.compute_na_state(comments, "alice", na_gates, lambda g, u: [])
|
||||
self.assertFalse(na_state["qa-review"]["declared"])
|
||||
self.assertEqual(na_state["qa-review"]["rejected"]["not_in_team"], ["mallory"])
|
||||
|
||||
def test_author_cannot_self_declare_na(self):
|
||||
cfg = sop.load_config(CONFIG_PATH)
|
||||
na_gates = cfg.get("n/a_gates", {})
|
||||
comments = [_comment("alice", "/sop-n/a qa-review N/A: I am the author")]
|
||||
na_state = sop.compute_na_state(comments, "alice", na_gates, lambda g, u: u)
|
||||
self.assertFalse(na_state["qa-review"]["declared"])
|
||||
|
||||
def test_parse_directives_separates_na_from_ack(self):
|
||||
directives, na_directives = sop.parse_directives(
|
||||
"/sop-ack comprehensive-testing\n/sop-n/a qa-review N/A: no surface",
|
||||
{},
|
||||
)
|
||||
self.assertEqual(len(directives), 1)
|
||||
self.assertEqual(directives[0][0], "sop-ack")
|
||||
self.assertEqual(len(na_directives), 1)
|
||||
self.assertEqual(na_directives[0][0], "sop-n/a")
|
||||
self.assertEqual(na_directives[0][1], "qa-review")
|
||||
self.assertIn("no surface", na_directives[0][2])
|
||||
|
||||
@@ -158,68 +158,8 @@ jobs:
|
||||
echo "NOTE: No warning in output (may be suppressed by log level)"
|
||||
fi
|
||||
|
||||
- name: Reproduce openclaw failure — pipe held OPEN, no EOF
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "=== keep-stdin-open pipe (the real openclaw / Claude Code case) ==="
|
||||
echo ""
|
||||
echo "Before the readline() fix this HANGS: main() did"
|
||||
echo " stdin.read(65536) -> on a pipe, blocks until 64KB OR EOF."
|
||||
echo "An MCP client sends one ~150B initialize and keeps stdin"
|
||||
echo "open waiting for the response, so the server never parsed"
|
||||
echo "the request and the client timed out (openclaw: 'MCP error"
|
||||
echo "-32000: Connection closed'). The earlier regular-file /"
|
||||
echo "heredoc-pipe steps PASSED through this bug because a file"
|
||||
echo "(or a closing heredoc) yields EOF immediately."
|
||||
echo ""
|
||||
|
||||
# Drive the server through a real pipe that stays OPEN: write
|
||||
# one initialize, do NOT close stdin, and require a response
|
||||
# within a hard timeout. read(65536) -> no output -> timeout
|
||||
# kills it -> FAIL. readline() -> immediate response -> PASS.
|
||||
python - <<'PYEOF'
|
||||
import json, subprocess, sys, time, select
|
||||
|
||||
proc = subprocess.Popen(
|
||||
[sys.executable, "a2a_mcp_server.py"],
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE,
|
||||
stderr=subprocess.STDOUT,
|
||||
env={**__import__("os").environ},
|
||||
)
|
||||
req = json.dumps({
|
||||
"jsonrpc": "2.0", "id": 1, "method": "initialize",
|
||||
"params": {"protocolVersion": "2024-11-05",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "keepopen", "version": "1"}},
|
||||
}) + "\n"
|
||||
proc.stdin.write(req.encode())
|
||||
proc.stdin.flush()
|
||||
# Deliberately DO NOT close proc.stdin — mirror a live MCP client.
|
||||
|
||||
deadline = time.time() + 15
|
||||
line = b""
|
||||
while time.time() < deadline:
|
||||
r, _, _ = select.select([proc.stdout], [], [], 1)
|
||||
if r:
|
||||
line = proc.stdout.readline()
|
||||
if line:
|
||||
break
|
||||
proc.kill()
|
||||
|
||||
if not line:
|
||||
print("FAIL: no response within 15s on an open pipe — "
|
||||
"stdin.read(65536) regression is back")
|
||||
sys.exit(1)
|
||||
resp = json.loads(line.decode())
|
||||
assert resp.get("id") == 1 and "result" in resp, \
|
||||
f"unexpected response: {line[:200]!r}"
|
||||
assert resp["result"]["serverInfo"]["name"] == "molecule", \
|
||||
f"wrong serverInfo: {line[:200]!r}"
|
||||
print("PASS: server answered initialize on a still-open pipe")
|
||||
PYEOF
|
||||
|
||||
- name: Run unit tests for stdio transport
|
||||
run: |
|
||||
set -euo pipefail
|
||||
echo "=== Running stdio transport unit tests ==="
|
||||
python -m pytest tests/test_a2a_mcp_server.py::TestStdioPipeAssertion tests/test_a2a_mcp_server.py::TestStdioKeepOpenPipe -v --no-cov
|
||||
python -m pytest tests/test_a2a_mcp_server.py::TestStdioPipeAssertion -v --no-cov
|
||||
|
||||
@@ -538,13 +538,11 @@ jobs:
|
||||
all-required:
|
||||
# Aggregator sentinel — RFC internal#219 §2 (Phase 4 — closes internal#286).
|
||||
#
|
||||
# Emits `CI / all-required (<event>)` where <event> is the workflow trigger
|
||||
# (e.g. `CI / all-required (pull_request)`, `CI / all-required (push)`).
|
||||
# Branch protection MUST be updated to require the event-suffixed name —
|
||||
# requiring `CI / all-required` (bare, no suffix) silently blocks all merges
|
||||
# because Gitea treats absent status contexts as pending (not skipped), and
|
||||
# no workflow emits the bare name. Fixed: BP now requires
|
||||
# `CI / all-required (pull_request)` per issue #1473.
|
||||
# Single stable required-status name that branch protection points at;
|
||||
# CI churns underneath in `needs:` without any protection edits. Mirrors
|
||||
# the molecule-controlplane Phase 2a impl shipped in CP PR#112 and
|
||||
# referenced by `internal#286` ("Phase 4 is a single small PR... mirrors
|
||||
# CP's existing one").
|
||||
#
|
||||
# Closes the failure mode where status_check_contexts on molecule-core/main
|
||||
# only listed `Secret scan` + `sop-tier-check` (the 2 meta-gates), so real
|
||||
|
||||
@@ -52,9 +52,5 @@ jobs:
|
||||
# explicitly instead of the combined state avoids false-pause when
|
||||
# non-blocking jobs (continue-on-error: true) have failed — those
|
||||
# failures pollute combined state but do not gate merges.
|
||||
# NOTE: the event-suffixed context name is intentional — branch protection
|
||||
# MUST require `CI / all-required (pull_request)` (with suffix), NOT the
|
||||
# bare `CI / all-required`. Gitea treats absent contexts as pending, not
|
||||
# skipped; requiring the bare name silently blocks all merges (issue #1473).
|
||||
PUSH_REQUIRED_CONTEXTS: CI / all-required (push)
|
||||
run: python3 .gitea/scripts/gitea-merge-queue.py
|
||||
|
||||
@@ -49,17 +49,13 @@ jobs:
|
||||
# bp-exempt: post-merge image publication side effect; CI / all-required gates source changes.
|
||||
build-and-push:
|
||||
name: Build & push canvas image
|
||||
# Dedicated publish/release lane (internal#462 / #394 / #399). Ship
|
||||
# path (on: push:main, canvas/**) — reserved capacity so a merged
|
||||
# canvas fix's image build never FIFO-queues behind PR required-CI.
|
||||
# The `publish` label resolves ONLY to the molecule-runner-publish-*
|
||||
# sub-pool (config.publish.yaml). HARD DEPENDENCY: this MUST land
|
||||
# AFTER the publish-lane runners are registered/advertising `publish`
|
||||
# — the earlier #599 `docker` label attempt queued indefinitely with
|
||||
# zero eligible runners precisely because the label was targeted
|
||||
# before any runner advertised it (see #576). The lane is registered
|
||||
# in this rollout (internal#462) so the precondition holds.
|
||||
runs-on: publish
|
||||
# REVERTED (infra/revert-docker-runner-label): `runs-on: ubuntu-latest` restored.
|
||||
# The `docker` label is not registered on any act_runner. `runs-on: [ubuntu-latest, docker]`
|
||||
# causes jobs to queue indefinitely with zero eligible runners — strictly worse than the
|
||||
# pre-#599 coin-flip (50% success rate). Once the `docker` label is registered on
|
||||
# ≥2 runners, re-apply the fix from #599 (infra/docker-runner-label).
|
||||
# See issue #576 + infra-lead pulse ~00:30Z.
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
|
||||
@@ -104,7 +104,7 @@ jobs:
|
||||
with:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Compute next version from PyPI latest and existing tags
|
||||
- name: Compute next version from PyPI latest
|
||||
id: bump
|
||||
run: |
|
||||
set -eu
|
||||
@@ -112,24 +112,9 @@ jobs:
|
||||
| python -c "import sys,json; print(json.load(sys.stdin)['info']['version'])")
|
||||
MAJOR=$(echo "$LATEST" | cut -d. -f1)
|
||||
MINOR=$(echo "$LATEST" | cut -d. -f2)
|
||||
TAG_LATEST=$(git tag --list "runtime-v${MAJOR}.${MINOR}.*" \
|
||||
| sed -E 's/^runtime-v//' \
|
||||
| grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' \
|
||||
| sort -V \
|
||||
| tail -1 || true)
|
||||
VERSION=$(PYPI_LATEST="$LATEST" TAG_LATEST="$TAG_LATEST" python - <<'PY'
|
||||
import os
|
||||
|
||||
def parse(v):
|
||||
return tuple(int(part) for part in v.split("."))
|
||||
|
||||
pypi = os.environ["PYPI_LATEST"]
|
||||
tag = os.environ.get("TAG_LATEST") or pypi
|
||||
base = max(parse(pypi), parse(tag))
|
||||
print(f"{base[0]}.{base[1]}.{base[2] + 1}")
|
||||
PY
|
||||
)
|
||||
echo "PyPI latest=$LATEST, latest runtime tag=${TAG_LATEST:-none} -> next=$VERSION"
|
||||
PATCH=$(echo "$LATEST" | cut -d. -f3)
|
||||
VERSION="${MAJOR}.${MINOR}.$((PATCH+1))"
|
||||
echo "PyPI latest=$LATEST -> next=$VERSION"
|
||||
if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
|
||||
echo "::error::computed version $VERSION does not match PEP 440 X.Y.Z"
|
||||
exit 1
|
||||
|
||||
@@ -66,10 +66,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
# Dedicated publish/release lane (internal#462 / #394 / #399). Ship
|
||||
# path (on: push tag runtime-v*) — reserved capacity, never FIFO
|
||||
# behind PR-CI. `publish` resolves only to molecule-runner-publish-*.
|
||||
runs-on: publish
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.version.outputs.version }}
|
||||
wheel_sha256: ${{ steps.wheel_hash.outputs.wheel_sha256 }}
|
||||
@@ -162,7 +159,6 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
python -m twine upload \
|
||||
--verbose \
|
||||
--repository pypi \
|
||||
--username __token__ \
|
||||
--password "$PYPI_TOKEN" \
|
||||
@@ -170,9 +166,7 @@ jobs:
|
||||
|
||||
cascade:
|
||||
needs: publish
|
||||
# Publish/release lane (internal#462) — downstream of the runtime
|
||||
# publish ship job; keep it on the reserved lane too.
|
||||
runs-on: publish
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Wait for PyPI to propagate the new version
|
||||
env:
|
||||
|
||||
@@ -54,14 +54,7 @@ env:
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
# Dedicated publish/release lane (internal#462 / #394 / #399). This
|
||||
# is a post-merge ship job (on: push:main) — it must NOT FIFO-compete
|
||||
# with PR required-CI on the shared pool (PR#1350's prod image build
|
||||
# was delayed ~25min this way). The `publish` label resolves ONLY to
|
||||
# the reserved molecule-runner-publish-* sub-pool (config.publish.yaml,
|
||||
# OUTSIDE the managed 1..20 range) so a merged fix's image build
|
||||
# starts immediately while PR-CI keeps the general pool.
|
||||
runs-on: publish
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -188,9 +181,7 @@ jobs:
|
||||
name: Production auto-deploy
|
||||
needs: build-and-push
|
||||
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
# Publish/release lane (internal#462) — production deploy of a merged
|
||||
# fix; reserved capacity, never queued behind PR-CI.
|
||||
runs-on: publish
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 75
|
||||
env:
|
||||
CP_URL: ${{ vars.PROD_CP_URL || 'https://api.moleculesai.app' }}
|
||||
|
||||
@@ -89,7 +89,6 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
secrets: read
|
||||
|
||||
jobs:
|
||||
# bp-exempt: PR review bot signal; required merge state is enforced by CI / all-required.
|
||||
|
||||
@@ -68,10 +68,7 @@ jobs:
|
||||
# bp-exempt: production redeploy is a side-effect workflow, not a merge gate.
|
||||
redeploy:
|
||||
if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
# Dedicated publish/release lane (internal#462 / #394 / #399).
|
||||
# Production tenant redeploy — a deploy action, reserved capacity so
|
||||
# it never queues behind PR-CI. `publish` -> molecule-runner-publish-*.
|
||||
runs-on: publish
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
|
||||
@@ -75,10 +75,7 @@ env:
|
||||
jobs:
|
||||
# bp-exempt: post-merge staging redeploy side effect; CI / all-required gates source changes.
|
||||
redeploy:
|
||||
# Dedicated publish/release lane (internal#462 / #394 / #399).
|
||||
# Post-merge staging redeploy — a deploy action, reserved capacity.
|
||||
# `publish` -> molecule-runner-publish-* sub-pool.
|
||||
runs-on: publish
|
||||
runs-on: ubuntu-latest
|
||||
# Phase 3 (RFC #219 §1): surface broken workflows without blocking.
|
||||
# mc#774: pre-existing continue-on-error mask; root-fix and remove, do not renew silently.
|
||||
continue-on-error: true
|
||||
|
||||
@@ -30,11 +30,6 @@ jobs:
|
||||
scan:
|
||||
name: Scan diff for credential-shaped strings
|
||||
runs-on: ubuntu-latest
|
||||
# Hard CI gate — must complete or the PR is unmergable. 10-minute ceiling
|
||||
# is generous for a diff-scan against a single SHA. If this times out, the
|
||||
# runner is frozen and holding a slot — the step timeout triggers clean
|
||||
# failure, releasing the runner for the next job.
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
@@ -138,14 +133,6 @@ jobs:
|
||||
[ -z "$f" ] && continue
|
||||
[ "$f" = "$SELF_GITHUB" ] && continue
|
||||
[ "$f" = "$SELF_GITEA" ] && continue
|
||||
# Test-fixture exclude (internal#425): the secrets-detector's OWN
|
||||
# unit-test corpus deliberately embeds credential-SHAPED example
|
||||
# strings to exercise the detector. Verified 2026-05-18 synthetic
|
||||
# (fabricated ghp_* fixtures, not real). Without this the scanner
|
||||
# self-trips on its own fixtures and fail-closes every deploy.
|
||||
# Same rationale as the SELF_* excludes above; gate NOT weakened
|
||||
# (all other paths still fully scanned).
|
||||
[ "$f" = "workspace-server/internal/secrets/patterns_test.go" ] && continue
|
||||
if [ -n "$DIFF_RANGE" ]; then
|
||||
ADDED=$(git diff --no-color --unified=0 "$BASE" "$HEAD" -- "$f" 2>/dev/null | grep -E '^\+[^+]' || true)
|
||||
else
|
||||
|
||||
@@ -16,7 +16,6 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
secrets: read
|
||||
|
||||
jobs:
|
||||
# bp-exempt: PR security review bot signal; required merge state is enforced by CI / all-required.
|
||||
|
||||
@@ -84,8 +84,11 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
# NOTE: `statuses: write` is the GitHub-Actions name for POST /statuses.
|
||||
# Gitea 1.22.6 may not gate on this permission key (it just checks the
|
||||
# token), but listing it explicitly documents intent for the next
|
||||
# platform-version upgrade.
|
||||
statuses: write
|
||||
secrets: read
|
||||
|
||||
jobs:
|
||||
all-items-acked:
|
||||
|
||||
@@ -71,7 +71,6 @@ jobs:
|
||||
permissions:
|
||||
contents: read
|
||||
pull-requests: read
|
||||
secrets: read
|
||||
steps:
|
||||
- name: Check out base branch (for the script)
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
+12
-12
@@ -57,24 +57,24 @@ See `CLAUDE.md` for a full list of environment variables and their purposes.
|
||||
|
||||
This repo is scoped to **code** (canvas, workspace, workspace-server, related
|
||||
infra). Public content (blog posts, marketing copy, OG images, SEO briefs,
|
||||
DevRel demos) lives in [`molecule-ai/docs`](https://git.moleculesai.app/molecule-ai/docs).
|
||||
DevRel demos) lives in [`Molecule-AI/docs`](https://git.moleculesai.app/molecule-ai/docs).
|
||||
The `Block forbidden paths` CI gate fails any PR that writes to `marketing/`
|
||||
or other removed paths — open against `molecule-ai/docs` instead.
|
||||
or other removed paths — open against `Molecule-AI/docs` instead.
|
||||
|
||||
| Content type | Target |
|
||||
|---|---|
|
||||
| Blog posts | `molecule-ai/docs` → `content/blog/<YYYY-MM-DD-slug>/` |
|
||||
| Doc pages | `molecule-ai/docs` → `content/docs/` |
|
||||
| Marketing copy / PMM positioning | `molecule-ai/docs` → `marketing/` |
|
||||
| OG images, visual assets | `molecule-ai/docs` → `app/` or `marketing/` |
|
||||
| SEO briefs | `molecule-ai/docs` → `marketing/` |
|
||||
| DevRel demos (runnable code) | Standalone repo under `molecule-ai/`, OR embedded in `molecule-ai/docs` |
|
||||
| Blog posts | `Molecule-AI/docs` → `content/blog/<YYYY-MM-DD-slug>/` |
|
||||
| Doc pages | `Molecule-AI/docs` → `content/docs/` |
|
||||
| Marketing copy / PMM positioning | `Molecule-AI/docs` → `marketing/` |
|
||||
| OG images, visual assets | `Molecule-AI/docs` → `app/` or `marketing/` |
|
||||
| SEO briefs | `Molecule-AI/docs` → `marketing/` |
|
||||
| DevRel demos (runnable code) | Standalone repo under `Molecule-AI/`, OR embedded in `Molecule-AI/docs` |
|
||||
| Launch checklists, internal tracking | GitHub Issues — **not** committed files |
|
||||
| Engineering docs (`docs/adr/`, `docs/architecture/`, `docs/incidents/`) | This repo (internal, not published) |
|
||||
| Live product pages (e.g. `canvas/src/app/pricing/page.tsx`) | This repo (these are app code, not marketing copy) |
|
||||
|
||||
If a PR fails the `Block forbidden paths` check, the contents belong in
|
||||
`molecule-ai/docs`. No CI drag, no Canvas E2E, content lands in minutes.
|
||||
`Molecule-AI/docs`. No CI drag, no Canvas E2E, content lands in minutes.
|
||||
|
||||
## Development Workflow
|
||||
|
||||
@@ -190,9 +190,9 @@ Runs the full regression suite against a fixture HTTP server. No network access
|
||||
Code in this repo lands in molecule-core. Some related runtime artifacts
|
||||
live in their own repos:
|
||||
|
||||
- [`molecule-ai/molecule-ai-workspace-runtime`](https://git.moleculesai.app/molecule-ai/molecule-ai-workspace-runtime) — Python adapter SDK (`molecule_runtime`) that runs inside containerized Molecule workspaces. Bridges Claude Code SDK / hermes / langgraph / etc. → A2A queue.
|
||||
- [`molecule-ai/molecule-sdk-python`](https://git.moleculesai.app/molecule-ai/molecule-sdk-python) — `A2AServer` + `RemoteAgentClient` for external agents that register over the public `/registry/register` flow.
|
||||
- [`molecule-ai/molecule-mcp-claude-channel`](https://git.moleculesai.app/molecule-ai/molecule-mcp-claude-channel) — Claude Code channel plugin. Bridges A2A traffic into a running Claude Code session via MCP `notifications/claude/channel`. Polling-based (no tunnel required); install inside Claude Code via `/plugin marketplace add https://git.moleculesai.app/molecule-ai/molecule-mcp-claude-channel.git` → `/plugin install molecule@molecule-channel`, then launch with `claude --dangerously-load-development-channels --channels plugin:molecule@molecule-channel`.
|
||||
- [`Molecule-AI/molecule-ai-workspace-runtime`](https://git.moleculesai.app/molecule-ai/molecule-ai-workspace-runtime) — Python adapter SDK (`molecule_runtime`) that runs inside containerized Molecule workspaces. Bridges Claude Code SDK / hermes / langgraph / etc. → A2A queue.
|
||||
- [`Molecule-AI/molecule-sdk-python`](https://git.moleculesai.app/molecule-ai/molecule-sdk-python) — `A2AServer` + `RemoteAgentClient` for external agents that register over the public `/registry/register` flow.
|
||||
- [`Molecule-AI/molecule-mcp-claude-channel`](https://git.moleculesai.app/molecule-ai/molecule-mcp-claude-channel) — Claude Code channel plugin. Bridges A2A traffic into a running Claude Code session via MCP `notifications/claude/channel`. Polling-based (no tunnel required); install with `claude --channels plugin:molecule@Molecule-AI/molecule-mcp-claude-channel`.
|
||||
|
||||
When extending the **A2A surface** in molecule-core (`workspace-server/internal/handlers/a2a_proxy.go` etc.), consider whether the change has a downstream impact on the runtime SDK or the channel plugin — they're versioned independently but share the wire shape.
|
||||
|
||||
|
||||
@@ -238,7 +238,7 @@ The result is not just “an agent that learns.” It is **an organization that
|
||||
- subscribe to one or more workspaces; peer messages surface as conversation turns; replies route back through Molecule's A2A
|
||||
- no tunnel, no public endpoint — the plugin self-registers each watched workspace as `delivery_mode=poll` and long-polls `/activity?since_id=…`
|
||||
- multi-tenant friendly: one plugin install can watch workspaces across multiple Molecule tenants (`MOLECULE_PLATFORM_URLS` per-workspace)
|
||||
- install via the standard marketplace flow: `/plugin marketplace add https://git.moleculesai.app/molecule-ai/molecule-mcp-claude-channel.git` → `/plugin install molecule@molecule-channel`, then launch with `claude --dangerously-load-development-channels --channels plugin:molecule@molecule-channel`
|
||||
- install via the standard marketplace flow: `/plugin marketplace add Molecule-AI/molecule-mcp-claude-channel` → `/plugin install molecule-channel@molecule-mcp-claude-channel`
|
||||
|
||||
## Built For Teams That Need More Than A Demo
|
||||
|
||||
|
||||
+1
-1
@@ -237,7 +237,7 @@ Molecule AI 并不是要替代下面这些 framework,而是把它们纳入更
|
||||
- 订阅一个或多个 workspace;peer 的消息会以 user-turn 出现,回复会经 Molecule A2A 路由出去
|
||||
- 无需公网隧道、无需公开端点 —— 插件启动时自动把每个 watched workspace 注册成 `delivery_mode=poll`,长轮询 `/activity?since_id=…`
|
||||
- 多租户友好:单次安装即可同时 watch 跨多个 Molecule 租户的 workspace(`MOLECULE_PLATFORM_URLS` 按 workspace 配置)
|
||||
- 通过标准 marketplace 流程安装:`/plugin marketplace add https://git.moleculesai.app/molecule-ai/molecule-mcp-claude-channel.git` → `/plugin install molecule@molecule-channel`,然后用 `claude --dangerously-load-development-channels --channels plugin:molecule@molecule-channel` 启动
|
||||
- 通过标准 marketplace 流程安装:`/plugin marketplace add Molecule-AI/molecule-mcp-claude-channel` → `/plugin install molecule-channel@molecule-mcp-claude-channel`
|
||||
|
||||
## 适合什么团队
|
||||
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
|
||||
// Marketing-launch SEO (mc#1486). These tests pin the public crawler
|
||||
// contract: anything that flips public marketing routes to disallow,
|
||||
// drops the sitemap from robots.txt, or removes the OG image
|
||||
// reference from root metadata should fail loudly here.
|
||||
|
||||
// next/font and the rest of the layout's runtime tree are not
|
||||
// vitest-compatible (next/font expects the Next.js compiler swc
|
||||
// transform). We import layout.tsx only for its exported `metadata`
|
||||
// constant — mock the font module to a constructor-returning stub.
|
||||
vi.mock("next/font/google", () => ({
|
||||
Inter: () => ({ variable: "--font-inter" }),
|
||||
JetBrains_Mono: () => ({ variable: "--font-jetbrains" }),
|
||||
}));
|
||||
|
||||
import robots from "../robots";
|
||||
import sitemap from "../sitemap";
|
||||
import { metadata } from "../layout";
|
||||
|
||||
describe("robots.ts", () => {
|
||||
it("allows public marketing routes and blocks authed/app routes", () => {
|
||||
const r = robots();
|
||||
expect(r.rules).toBeDefined();
|
||||
const rule = Array.isArray(r.rules) ? r.rules[0] : r.rules!;
|
||||
expect(rule.userAgent).toBe("*");
|
||||
const allow = Array.isArray(rule.allow) ? rule.allow : [rule.allow];
|
||||
expect(allow).toEqual(expect.arrayContaining(["/", "/pricing", "/blog"]));
|
||||
const disallow = Array.isArray(rule.disallow)
|
||||
? rule.disallow
|
||||
: [rule.disallow];
|
||||
expect(disallow).toEqual(
|
||||
expect.arrayContaining(["/api/", "/orgs", "/cp/"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("declares the sitemap URL", () => {
|
||||
const r = robots();
|
||||
expect(r.sitemap).toMatch(/\/sitemap\.xml$/);
|
||||
});
|
||||
|
||||
it("declares a canonical host", () => {
|
||||
const r = robots();
|
||||
expect(r.host).toMatch(/^https:\/\//);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sitemap.ts", () => {
|
||||
it("includes apex, pricing, and the live blog post", () => {
|
||||
const entries = sitemap();
|
||||
const urls = entries.map((e) => e.url);
|
||||
expect(urls.some((u) => u.endsWith("/"))).toBe(true);
|
||||
expect(urls.some((u) => u.endsWith("/pricing"))).toBe(true);
|
||||
expect(
|
||||
urls.some((u) => u.includes("/blog/2026-04-20-chrome-devtools-mcp")),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT include authed/app routes", () => {
|
||||
const entries = sitemap();
|
||||
const urls = entries.map((e) => e.url);
|
||||
expect(urls.some((u) => u.includes("/orgs"))).toBe(false);
|
||||
expect(urls.some((u) => u.includes("/api/"))).toBe(false);
|
||||
});
|
||||
|
||||
it("sets a non-zero priority and a valid changeFrequency on every entry", () => {
|
||||
const valid = new Set([
|
||||
"always",
|
||||
"hourly",
|
||||
"daily",
|
||||
"weekly",
|
||||
"monthly",
|
||||
"yearly",
|
||||
"never",
|
||||
]);
|
||||
for (const e of sitemap()) {
|
||||
expect(e.priority).toBeGreaterThan(0);
|
||||
expect(valid.has(String(e.changeFrequency))).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("root layout metadata", () => {
|
||||
it("sets a templated title + non-empty description", () => {
|
||||
const t = metadata.title as { default: string; template: string };
|
||||
expect(t.default).toMatch(/Molecule AI/);
|
||||
expect(t.template).toMatch(/%s/);
|
||||
expect((metadata.description ?? "").length).toBeGreaterThan(50);
|
||||
});
|
||||
|
||||
it("declares OG + Twitter text fields (image comes from opengraph-image.tsx)", () => {
|
||||
const og = metadata.openGraph;
|
||||
expect(og).toBeDefined();
|
||||
expect((og as { title: string }).title).toMatch(/Molecule AI/);
|
||||
expect((og as { description: string }).description.length).toBeGreaterThan(
|
||||
50,
|
||||
);
|
||||
const tw = metadata.twitter;
|
||||
expect(tw).toBeDefined();
|
||||
// Next.js typings narrow twitter.card to a union — assert via cast.
|
||||
expect((tw as { card: string }).card).toBe("summary_large_image");
|
||||
});
|
||||
|
||||
it("sets a canonical alternate", () => {
|
||||
expect(metadata.alternates?.canonical).toBe("/");
|
||||
});
|
||||
|
||||
it("enables indexing at the metadata level (robots.ts owns per-route)", () => {
|
||||
const r = metadata.robots as { index: boolean; follow: boolean };
|
||||
expect(r.index).toBe(true);
|
||||
expect(r.follow).toBe(true);
|
||||
});
|
||||
});
|
||||
+2
-140
@@ -27,78 +27,9 @@ import {
|
||||
themeBootScript,
|
||||
} from "@/lib/theme-cookie";
|
||||
|
||||
// Marketing-launch SEO (mc#1486). Canonical apex is app.moleculesai.app —
|
||||
// tenant subdomains (<slug>.moleculesai.app) reuse the same Next.js build
|
||||
// but are gated behind auth (AuthGate redirects anonymous → /cp/auth/login)
|
||||
// and are de-indexed in robots.ts. The metadata here applies to the
|
||||
// public marketing surface served from the apex host.
|
||||
//
|
||||
// Override per-route by exporting a page-level `metadata`/`generateMetadata`
|
||||
// — Next.js merges page metadata over layout metadata using
|
||||
// `title.template` for "<page> | Molecule AI" composition.
|
||||
const SITE_URL =
|
||||
process.env.NEXT_PUBLIC_SITE_URL ?? "https://app.moleculesai.app";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
metadataBase: new URL(SITE_URL),
|
||||
title: {
|
||||
default: "Molecule AI — the AI org chart canvas",
|
||||
template: "%s | Molecule AI",
|
||||
},
|
||||
description:
|
||||
"Molecule AI is an org-chart canvas for AI agent teams. Wire Claude Code, Codex, Hermes, and OpenClaw agents into a governed multi-agent workspace with credit metering, audit, and one-click runtime provisioning.",
|
||||
applicationName: "Molecule AI",
|
||||
keywords: [
|
||||
"AI agents",
|
||||
"multi-agent",
|
||||
"agent orchestration",
|
||||
"AI org chart",
|
||||
"Claude Code",
|
||||
"Codex",
|
||||
"MCP",
|
||||
"agent governance",
|
||||
"A2A",
|
||||
"agent runtime",
|
||||
],
|
||||
authors: [{ name: "Molecule AI" }],
|
||||
creator: "Molecule AI",
|
||||
publisher: "Molecule AI",
|
||||
alternates: { canonical: "/" },
|
||||
// OG + Twitter images come from the file-convention sibling
|
||||
// `opengraph-image.tsx` — Next.js auto-attaches them to og:image
|
||||
// and twitter:image when present at the segment root. We keep the
|
||||
// text fields here so they win over per-page metadata when a page
|
||||
// doesn't override them. `images: []` as the structural fallback
|
||||
// for hosts that won't follow the file convention; the real URL
|
||||
// is injected by Next.js at build time from opengraph-image.tsx.
|
||||
openGraph: {
|
||||
type: "website",
|
||||
siteName: "Molecule AI",
|
||||
url: SITE_URL,
|
||||
title: "Molecule AI — the AI org chart canvas",
|
||||
description:
|
||||
"Wire Claude Code, Codex, Hermes, and OpenClaw agents into a governed multi-agent workspace. Credit metering, audit, and one-click runtime provisioning.",
|
||||
locale: "en_US",
|
||||
},
|
||||
twitter: {
|
||||
card: "summary_large_image",
|
||||
title: "Molecule AI — the AI org chart canvas",
|
||||
description:
|
||||
"Wire Claude Code, Codex, Hermes, and OpenClaw agents into a governed multi-agent workspace.",
|
||||
},
|
||||
icons: {
|
||||
icon: "/molecule-icon.png",
|
||||
apple: "/molecule-icon.png",
|
||||
},
|
||||
// robots.ts owns the per-route allow/disallow contract; this is the
|
||||
// header-level fallback for routes the crawler reaches before
|
||||
// robots.txt resolves. Default = index public marketing routes;
|
||||
// app/auth/api/orgs are noindex'd by robots.ts.
|
||||
robots: {
|
||||
index: true,
|
||||
follow: true,
|
||||
googleBot: { index: true, follow: true, "max-image-preview": "large" },
|
||||
},
|
||||
title: "Molecule AI",
|
||||
description: "AI Org Chart Canvas",
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
@@ -163,75 +94,6 @@ export default async function RootLayout({
|
||||
nonce={nonce}
|
||||
dangerouslySetInnerHTML={{ __html: themeBootScript }}
|
||||
/>
|
||||
{/*
|
||||
* JSON-LD structured data (mc#1486). Two graph nodes:
|
||||
*
|
||||
* - Organization: surfaces the brand to Google Knowledge
|
||||
* Graph + Bing entity index. URL+logo+sameAs are the
|
||||
* minimum recommended set for new brands without a
|
||||
* Wikipedia page.
|
||||
*
|
||||
* - WebSite: enables the sitelinks search box and tells
|
||||
* crawlers the canonical site URL when the same content
|
||||
* is reachable via multiple subdomains (apex + tenant).
|
||||
*
|
||||
* Type-application/ld+json runs synchronously without
|
||||
* executing JS, so 'strict-dynamic' isn't required — we still
|
||||
* carry the nonce because production CSP's default-src 'self'
|
||||
* applies to any <script> element. The "type" attribute is
|
||||
* what keeps the browser from running the body as JS, but
|
||||
* CSP nonces are gated on the element not the type, so we
|
||||
* include the nonce too.
|
||||
*/}
|
||||
<script
|
||||
type="application/ld+json"
|
||||
nonce={nonce}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: JSON.stringify({
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "Organization",
|
||||
"@id": `${SITE_URL}#organization`,
|
||||
name: "Molecule AI",
|
||||
url: SITE_URL,
|
||||
logo: `${SITE_URL}/molecule-icon.png`,
|
||||
sameAs: [
|
||||
"https://github.com/molecule-ai",
|
||||
"https://x.com/moleculeai",
|
||||
],
|
||||
},
|
||||
{
|
||||
"@type": "WebSite",
|
||||
"@id": `${SITE_URL}#website`,
|
||||
url: SITE_URL,
|
||||
name: "Molecule AI",
|
||||
publisher: { "@id": `${SITE_URL}#organization` },
|
||||
inLanguage: "en-US",
|
||||
},
|
||||
{
|
||||
"@type": "SoftwareApplication",
|
||||
"@id": `${SITE_URL}#software`,
|
||||
name: "Molecule AI",
|
||||
applicationCategory: "DeveloperApplication",
|
||||
operatingSystem: "Web",
|
||||
description:
|
||||
"Org-chart canvas for AI agent teams with credit metering, audit, and one-click runtime provisioning.",
|
||||
url: SITE_URL,
|
||||
offers: {
|
||||
"@type": "AggregateOffer",
|
||||
priceCurrency: "USD",
|
||||
lowPrice: "0",
|
||||
highPrice: "99",
|
||||
offerCount: "3",
|
||||
url: `${SITE_URL}/pricing`,
|
||||
},
|
||||
publisher: { "@id": `${SITE_URL}#organization` },
|
||||
},
|
||||
],
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</head>
|
||||
<body className={`bg-surface text-ink ${interFont.variable} ${monoFont.variable}`}>
|
||||
<ThemeProvider initialTheme={theme}>
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import { ImageResponse } from "next/og";
|
||||
|
||||
// Marketing-launch SEO (mc#1486). Next.js App-Router file-system OG
|
||||
// convention: served as `/opengraph-image` and auto-attached as
|
||||
// `og:image` + `twitter:image`. Dynamic (not a static PNG in /public)
|
||||
// so we can iterate the brand mark + tagline pre-launch without
|
||||
// churning a binary blob in git history.
|
||||
export const runtime = "edge";
|
||||
|
||||
export const alt = "Molecule AI — the AI org chart canvas";
|
||||
export const size = { width: 1200, height: 630 };
|
||||
export const contentType = "image/png";
|
||||
|
||||
export default function OG() {
|
||||
return new ImageResponse(
|
||||
(
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
height: "100%",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
alignItems: "flex-start",
|
||||
justifyContent: "center",
|
||||
padding: "80px",
|
||||
background:
|
||||
"linear-gradient(135deg, #0a0a0a 0%, #1a1a2e 60%, #16213e 100%)",
|
||||
color: "#ffffff",
|
||||
fontFamily: "system-ui, -apple-system, sans-serif",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 28,
|
||||
color: "#a3a3c2",
|
||||
letterSpacing: "0.18em",
|
||||
textTransform: "uppercase",
|
||||
marginBottom: 24,
|
||||
}}
|
||||
>
|
||||
Molecule AI
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 76,
|
||||
fontWeight: 700,
|
||||
lineHeight: 1.05,
|
||||
letterSpacing: "-0.02em",
|
||||
maxWidth: 980,
|
||||
}}
|
||||
>
|
||||
The AI org chart canvas
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 32,
|
||||
color: "#c8c8d8",
|
||||
marginTop: 32,
|
||||
lineHeight: 1.3,
|
||||
maxWidth: 980,
|
||||
}}
|
||||
>
|
||||
Wire Claude Code, Codex, Hermes, and OpenClaw agents into a governed
|
||||
multi-agent workspace.
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 80,
|
||||
bottom: 80,
|
||||
fontSize: 22,
|
||||
color: "#7a7a96",
|
||||
display: "flex",
|
||||
}}
|
||||
>
|
||||
moleculesai.app
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
{ ...size },
|
||||
);
|
||||
}
|
||||
@@ -212,7 +212,7 @@ function AccountBar({ session }: { session: Session }) {
|
||||
// edge cases (jsdom, blocked navigation) where it doesn't.
|
||||
setSigningOut(false);
|
||||
}}
|
||||
className="rounded border border-line bg-surface-card px-3 py-1 text-xs text-ink hover:bg-surface-card disabled:opacity-50"
|
||||
className="rounded border border-line bg-surface-card px-3 py-1 text-xs text-ink hover:bg-surface-card disabled:opacity-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-1"
|
||||
aria-label="Sign out"
|
||||
>
|
||||
{signingOut ? "Signing out…" : "Sign out"}
|
||||
|
||||
@@ -103,7 +103,7 @@ export default function Home() {
|
||||
setHydrationError(null);
|
||||
window.location.reload();
|
||||
}}
|
||||
className="px-4 py-2 bg-accent-strong hover:bg-accent text-white rounded-md text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
className="px-4 py-2 bg-accent-strong hover:bg-accent text-white rounded-md text-sm"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
@@ -115,9 +115,7 @@ export default function Home() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<main aria-label="Agent canvas">
|
||||
<Canvas />
|
||||
</main>
|
||||
<Canvas />
|
||||
<Legend />
|
||||
<CommunicationOverlay />
|
||||
{hydrationError && (
|
||||
@@ -136,7 +134,7 @@ export default function Home() {
|
||||
setHydrationError(null);
|
||||
window.location.reload();
|
||||
}}
|
||||
className="px-4 py-2 bg-accent-strong hover:bg-accent text-white rounded-md text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
className="px-4 py-2 bg-accent-strong hover:bg-accent text-white rounded-md text-sm"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
@@ -178,7 +176,7 @@ brew services start redis`}</pre>
|
||||
</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-accent-strong hover:bg-accent text-white rounded-md text-sm mt-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
className="px-4 py-2 bg-accent-strong hover:bg-accent text-white rounded-md text-sm mt-2"
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
// Marketing-launch SEO (mc#1486). Next.js App-Router robots convention:
|
||||
// this file is served as `/robots.txt` at build time and is the single
|
||||
// source of truth for crawler allow/disallow.
|
||||
//
|
||||
// Contract:
|
||||
// - Public marketing routes (/, /pricing, /blog/*) are crawlable.
|
||||
// - Authed/app routes (/orgs, /api/*) are noindex'd. They render
|
||||
// useful content only after a session round-trip, so a crawler hit
|
||||
// just wastes our crawl budget and exposes endpoint shapes.
|
||||
// - Tenant subdomains (<slug>.moleculesai.app) share this build but
|
||||
// are blocked at the host level by the canvas middleware sending
|
||||
// an `X-Robots-Tag: noindex` header — robots.txt is per-host and
|
||||
// this file's `host` field claims the apex as canonical.
|
||||
//
|
||||
// Note: `sitemap` is published via the sibling `sitemap.ts` route; we
|
||||
// reference it explicitly here so crawlers don't have to guess.
|
||||
const SITE_URL =
|
||||
process.env.NEXT_PUBLIC_SITE_URL ?? "https://app.moleculesai.app";
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
return {
|
||||
rules: [
|
||||
{
|
||||
userAgent: "*",
|
||||
allow: ["/", "/pricing", "/blog"],
|
||||
// Authed app surface + API + transient checkout returns. The
|
||||
// /orgs route boots the org-selector behind AuthGate; even
|
||||
// though SSR returns markup, that markup is a login wall when
|
||||
// hit by an unauthenticated crawler, so indexing it dilutes
|
||||
// brand searches with a "Please sign in" snippet.
|
||||
disallow: [
|
||||
"/orgs",
|
||||
"/orgs/",
|
||||
"/api/",
|
||||
"/cp/",
|
||||
"/checkout/",
|
||||
],
|
||||
},
|
||||
],
|
||||
sitemap: `${SITE_URL}/sitemap.xml`,
|
||||
host: SITE_URL,
|
||||
};
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import type { MetadataRoute } from "next";
|
||||
|
||||
// Marketing-launch SEO (mc#1486). App-Router sitemap convention: this
|
||||
// file is served as `/sitemap.xml` and enumerates the public marketing
|
||||
// surface for search crawlers + AI training pipelines.
|
||||
//
|
||||
// Scope deliberately narrow:
|
||||
// - Apex landing, pricing, and the (currently single) blog post.
|
||||
// - Authed app routes are excluded — they're disallowed in robots.ts
|
||||
// and would appear as "Please sign in" wall to a crawler.
|
||||
//
|
||||
// `lastModified` uses a build-time timestamp rather than per-route
|
||||
// fs.stat so the same value applies regardless of where the build
|
||||
// runs (Vercel/Railway/local). When we add CMS-backed blog content,
|
||||
// swap to a per-entry timestamp from the source-of-truth metadata.
|
||||
const SITE_URL =
|
||||
process.env.NEXT_PUBLIC_SITE_URL ?? "https://app.moleculesai.app";
|
||||
|
||||
const BUILD_DATE = new Date();
|
||||
|
||||
export default function sitemap(): MetadataRoute.Sitemap {
|
||||
return [
|
||||
{
|
||||
url: `${SITE_URL}/`,
|
||||
lastModified: BUILD_DATE,
|
||||
changeFrequency: "weekly",
|
||||
priority: 1.0,
|
||||
},
|
||||
{
|
||||
url: `${SITE_URL}/pricing`,
|
||||
lastModified: BUILD_DATE,
|
||||
changeFrequency: "weekly",
|
||||
priority: 0.9,
|
||||
},
|
||||
{
|
||||
url: `${SITE_URL}/blog/2026-04-20-chrome-devtools-mcp`,
|
||||
lastModified: new Date("2026-04-20"),
|
||||
changeFrequency: "monthly",
|
||||
priority: 0.6,
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -132,7 +132,7 @@ export function AuditTrailPanel({ workspaceId }: Props) {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div role="status" aria-live="polite" className="flex items-center justify-center h-32">
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<span className="text-xs text-ink-mid">Loading audit trail…</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
|
||||
/** Org-wide broadcast banner.
|
||||
*
|
||||
* Rendered at the top of the canvas (below the toolbar) whenever the store
|
||||
* holds one or more unread BROADCAST_MESSAGE entries. Each entry shows:
|
||||
* - sender name (workspace that issued the broadcast)
|
||||
* - the message text
|
||||
* - a dismiss button
|
||||
*
|
||||
* Dismissing an entry removes it from the store via consumeBroadcastMessages.
|
||||
* The dismissed state is intentionally ephemeral — dismissed broadcasts reappear
|
||||
* on page refresh since they are not persisted server-side; this is intentional
|
||||
* (the platform's activity log already provides the audit trail).
|
||||
*/
|
||||
export function BroadcastBanner() {
|
||||
const broadcastMessages = useCanvasStore((s) => s.broadcastMessages);
|
||||
const dismissBroadcastMessage = useCanvasStore((s) => s.dismissBroadcastMessage);
|
||||
|
||||
const handleDismiss = useCallback(
|
||||
(id: string) => {
|
||||
dismissBroadcastMessage(id);
|
||||
},
|
||||
[dismissBroadcastMessage],
|
||||
);
|
||||
|
||||
if (broadcastMessages.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed top-16 left-1/2 -translate-x-1/2 z-30 flex flex-col gap-2 items-center w-full max-w-xl px-4 pointer-events-none">
|
||||
{broadcastMessages.map((msg) => (
|
||||
<div
|
||||
key={msg.id}
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
aria-atomic="true"
|
||||
className="pointer-events-auto w-full bg-blue-950/80 backdrop-blur-md border border-blue-700/50 rounded-xl px-5 py-3 shadow-2xl shadow-black/40 animate-in slide-in-from-top duration-300"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Megaphone icon */}
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="w-7 h-7 rounded-lg bg-blue-900/50 flex items-center justify-center shrink-0 mt-0.5"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-blue-300"
|
||||
>
|
||||
<path d="M3 11l18-5v12L3 13v-2z" />
|
||||
<path d="M11.6 16.8a3 3 0 1 1-5.8-1.6" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs text-blue-300 font-semibold">
|
||||
Broadcast from{" "}
|
||||
<span className="text-blue-100">{msg.sender}</span>
|
||||
</div>
|
||||
<div className="text-sm text-blue-50 mt-0.5 leading-snug break-words">
|
||||
{msg.message}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dismiss button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleDismiss(msg.id)}
|
||||
aria-label="Dismiss broadcast"
|
||||
className="shrink-0 w-6 h-6 rounded text-blue-400 hover:text-blue-200 hover:bg-blue-800/50 flex items-center justify-center transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-blue-400 focus-visible:ring-offset-1 focus-visible:ring-offset-blue-950"
|
||||
>
|
||||
<svg
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M18 6 6 18M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import { CreateWorkspaceButton } from "./CreateWorkspaceDialog";
|
||||
import { ContextMenu } from "./ContextMenu";
|
||||
import { TemplatePalette } from "./TemplatePalette";
|
||||
import { ApprovalBanner } from "./ApprovalBanner";
|
||||
import { BroadcastBanner } from "./BroadcastBanner";
|
||||
import { BundleDropZone } from "./BundleDropZone";
|
||||
import { EmptyState } from "./EmptyState";
|
||||
import { OnboardingWizard } from "./OnboardingWizard";
|
||||
@@ -367,6 +368,7 @@ function CanvasInner() {
|
||||
<OnboardingWizard />
|
||||
<Toolbar />
|
||||
<ApprovalBanner />
|
||||
<BroadcastBanner />
|
||||
<BundleDropZone />
|
||||
<TemplatePalette />
|
||||
<SidePanel />
|
||||
|
||||
@@ -133,13 +133,13 @@ export function ConversationTraceModal({ open, workspaceId: _workspaceId, onClos
|
||||
{/* Timeline */}
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4">
|
||||
{loading && (
|
||||
<div role="status" aria-live="polite" className="text-xs text-ink-mid text-center py-8">
|
||||
<div className="text-xs text-ink-mid text-center py-8">
|
||||
Loading trace from all workspaces...
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && entries.length === 0 && (
|
||||
<div role="status" aria-live="polite" className="text-xs text-ink-mid text-center py-8">
|
||||
<div className="text-xs text-ink-mid text-center py-8">
|
||||
No activity found
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -105,7 +105,7 @@ export function EmptyState() {
|
||||
|
||||
{/* Template grid */}
|
||||
{loading ? (
|
||||
<div role="status" aria-live="polite" className="flex items-center justify-center gap-2 text-xs text-ink-mid py-4">
|
||||
<div className="flex items-center justify-center gap-2 text-xs text-ink-mid py-4">
|
||||
<Spinner />
|
||||
Loading templates...
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
// ($AGENT_URL). They ARE NOT filled in server-side because the
|
||||
// server doesn't know where the operator's agent will live.
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import * as Dialog from "@radix-ui/react-dialog";
|
||||
|
||||
type Tab = "python" | "curl" | "claude" | "mcp" | "hermes" | "codex" | "openclaw" | "kimi" | "fields";
|
||||
@@ -84,33 +84,6 @@ export function ExternalConnectModal({ info, onClose }: Props) {
|
||||
: "python";
|
||||
const [tab, setTab] = useState<Tab>(initialTab);
|
||||
const [copiedKey, setCopiedKey] = useState<string | null>(null);
|
||||
const tabRefs = useRef<Map<Tab, HTMLButtonElement | null>>(new Map());
|
||||
|
||||
const handleTabKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLButtonElement>, current: Tab, tabs: Tab[]) => {
|
||||
const idx = tabs.indexOf(current);
|
||||
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
const next = tabs[(idx + 1) % tabs.length];
|
||||
setTab(next);
|
||||
tabRefs.current.get(next)?.focus();
|
||||
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
const prev = tabs[(idx - 1 + tabs.length) % tabs.length];
|
||||
setTab(prev);
|
||||
tabRefs.current.get(prev)?.focus();
|
||||
} else if (e.key === "Home") {
|
||||
e.preventDefault();
|
||||
setTab(tabs[0]);
|
||||
tabRefs.current.get(tabs[0])?.focus();
|
||||
} else if (e.key === "End") {
|
||||
e.preventDefault();
|
||||
setTab(tabs[tabs.length - 1]);
|
||||
tabRefs.current.get(tabs[tabs.length - 1])?.focus();
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const copy = useCallback(async (value: string, key: string) => {
|
||||
try {
|
||||
@@ -187,19 +160,6 @@ export function ExternalConnectModal({ info, onClose }: Props) {
|
||||
`MOLECULE_WORKSPACE_TOKEN=${info.auth_token}`,
|
||||
);
|
||||
|
||||
// Build the tab list once so both the tab bar and keyboard handler
|
||||
// share the same ordered array. Computed here (after all filled* vars)
|
||||
// so TypeScript's block-scoping analysis can reach them.
|
||||
const tabList: Tab[] = [];
|
||||
if (filledUniversalMcp) tabList.push("mcp");
|
||||
tabList.push("python");
|
||||
if (filledChannel) tabList.push("claude");
|
||||
if (filledHermes) tabList.push("hermes");
|
||||
if (filledCodex) tabList.push("codex");
|
||||
if (filledOpenClaw) tabList.push("openclaw");
|
||||
if (filledKimi) tabList.push("kimi");
|
||||
tabList.push("curl", "fields");
|
||||
|
||||
return (
|
||||
<Dialog.Root open onOpenChange={(o) => !o && onClose()}>
|
||||
<Dialog.Portal>
|
||||
@@ -220,18 +180,34 @@ export function ExternalConnectModal({ info, onClose }: Props) {
|
||||
aria-label="Connection snippet format"
|
||||
className="mt-4 flex gap-1 border-b border-line"
|
||||
>
|
||||
{tabList.map((t) => (
|
||||
{(() => {
|
||||
// Build the tab order dynamically. Claude Code first
|
||||
// (when offered) since it's the simplest setup; Python
|
||||
// SDK second (full register+heartbeat+inbound); Universal
|
||||
// MCP third (any MCP-aware runtime, outbound-only); curl
|
||||
// for one-shot register; Fields for raw values.
|
||||
// Tab order: Universal MCP first (default, runtime-
|
||||
// agnostic primitives), then runtime-specific channel/
|
||||
// SDK tabs, then curl + Fields. Each runtime tab only
|
||||
// appears when the platform supplies the snippet — no
|
||||
// dead "tab missing snippet" UX.
|
||||
const tabs: Tab[] = [];
|
||||
if (filledUniversalMcp) tabs.push("mcp");
|
||||
tabs.push("python");
|
||||
if (filledChannel) tabs.push("claude");
|
||||
if (filledHermes) tabs.push("hermes");
|
||||
if (filledCodex) tabs.push("codex");
|
||||
if (filledOpenClaw) tabs.push("openclaw");
|
||||
if (filledKimi) tabs.push("kimi");
|
||||
tabs.push("curl", "fields");
|
||||
return tabs;
|
||||
})().map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
role="tab"
|
||||
id={`tab-${t}`}
|
||||
aria-selected={tab === t}
|
||||
aria-controls={`panel-${t}`}
|
||||
tabIndex={tab === t ? 0 : -1}
|
||||
ref={(el) => { tabRefs.current.set(t, el); }}
|
||||
onClick={() => setTab(t)}
|
||||
onKeyDown={(e) => handleTabKeyDown(e, t, tabList)}
|
||||
className={`px-3 py-2 text-sm border-b-2 -mb-px transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface ${
|
||||
tab === t
|
||||
? "border-accent text-ink"
|
||||
@@ -259,39 +235,18 @@ export function ExternalConnectModal({ info, onClose }: Props) {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Snippet area — all panels always in the DOM so aria-controls
|
||||
targets are stable. Hidden panels use aria-hidden so screen
|
||||
readers skip them; active panel uses role=tabpanel with
|
||||
aria-labelledby pointing to the tab button. */}
|
||||
<div className="mt-3" data-testid="snippet-panels">
|
||||
{/* Claude Code tab */}
|
||||
<div
|
||||
id="panel-claude"
|
||||
data-testid="panel-claude"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-claude"
|
||||
hidden={tab !== "claude" || !filledChannel}
|
||||
className={tab === "claude" && filledChannel ? "" : "hidden"}
|
||||
>
|
||||
{filledChannel && (
|
||||
<SnippetBlock
|
||||
value={filledChannel}
|
||||
label="Claude Code channel — polls workspace's A2A; no tunnel needed"
|
||||
copyKey="claude"
|
||||
copied={copiedKey === "claude"}
|
||||
onCopy={() => copy(filledChannel, "claude")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* Python SDK tab */}
|
||||
<div
|
||||
id="panel-python"
|
||||
data-testid="panel-python"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-python"
|
||||
hidden={tab !== "python"}
|
||||
className={tab === "python" ? "" : "hidden"}
|
||||
>
|
||||
{/* Snippet area */}
|
||||
<div className="mt-3">
|
||||
{tab === "claude" && filledChannel && (
|
||||
<SnippetBlock
|
||||
value={filledChannel}
|
||||
label="Claude Code channel — polls workspace's A2A; no tunnel needed"
|
||||
copyKey="claude"
|
||||
copied={copiedKey === "claude"}
|
||||
onCopy={() => copy(filledChannel, "claude")}
|
||||
/>
|
||||
)}
|
||||
{tab === "python" && (
|
||||
<SnippetBlock
|
||||
value={filledPython}
|
||||
label="Python SDK — includes heartbeat loop (push-mode, needs public URL)"
|
||||
@@ -299,16 +254,8 @@ export function ExternalConnectModal({ info, onClose }: Props) {
|
||||
copied={copiedKey === "python"}
|
||||
onCopy={() => copy(filledPython, "python")}
|
||||
/>
|
||||
</div>
|
||||
{/* curl tab */}
|
||||
<div
|
||||
id="panel-curl"
|
||||
data-testid="panel-curl"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-curl"
|
||||
hidden={tab !== "curl"}
|
||||
className={tab === "curl" ? "" : "hidden"}
|
||||
>
|
||||
)}
|
||||
{tab === "curl" && (
|
||||
<SnippetBlock
|
||||
value={filledCurl}
|
||||
label="curl — one-shot register only (no heartbeat)"
|
||||
@@ -316,111 +263,53 @@ export function ExternalConnectModal({ info, onClose }: Props) {
|
||||
copied={copiedKey === "curl"}
|
||||
onCopy={() => copy(filledCurl, "curl")}
|
||||
/>
|
||||
</div>
|
||||
{/* Universal MCP tab */}
|
||||
<div
|
||||
id="panel-mcp"
|
||||
data-testid="panel-mcp"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-mcp"
|
||||
hidden={tab !== "mcp" || !filledUniversalMcp}
|
||||
className={tab === "mcp" && filledUniversalMcp ? "" : "hidden"}
|
||||
>
|
||||
{filledUniversalMcp && (
|
||||
<SnippetBlock
|
||||
value={filledUniversalMcp}
|
||||
label="Universal MCP — standalone register + heartbeat + tools for any MCP-aware runtime (Claude Code, hermes, codex). Pair with Python or Claude Code tab if you need inbound A2A delivery."
|
||||
copyKey="mcp"
|
||||
copied={copiedKey === "mcp"}
|
||||
onCopy={() => copy(filledUniversalMcp, "mcp")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* Hermes tab */}
|
||||
<div
|
||||
id="panel-hermes"
|
||||
data-testid="panel-hermes"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-hermes"
|
||||
hidden={tab !== "hermes" || !filledHermes}
|
||||
className={tab === "hermes" && filledHermes ? "" : "hidden"}
|
||||
>
|
||||
{filledHermes && (
|
||||
<SnippetBlock
|
||||
value={filledHermes}
|
||||
label="Hermes channel — bridges this workspace's A2A traffic into your hermes-agent session as platform messages (push parity with Claude Code). Long-poll based; no tunnel needed."
|
||||
copyKey="hermes"
|
||||
copied={copiedKey === "hermes"}
|
||||
onCopy={() => copy(filledHermes, "hermes")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* Codex tab */}
|
||||
<div
|
||||
id="panel-codex"
|
||||
data-testid="panel-codex"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-codex"
|
||||
hidden={tab !== "codex" || !filledCodex}
|
||||
className={tab === "codex" && filledCodex ? "" : "hidden"}
|
||||
>
|
||||
{filledCodex && (
|
||||
<SnippetBlock
|
||||
value={filledCodex}
|
||||
label="Codex MCP config — wires the molecule MCP server into ~/.codex/config.toml. Outbound tools today; inbound A2A push needs the Python SDK tab paired in (codex's MCP runtime doesn't route arbitrary notifications/* yet)."
|
||||
copyKey="codex"
|
||||
copied={copiedKey === "codex"}
|
||||
onCopy={() => copy(filledCodex, "codex")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* OpenClaw tab */}
|
||||
<div
|
||||
id="panel-openclaw"
|
||||
data-testid="panel-openclaw"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-openclaw"
|
||||
hidden={tab !== "openclaw" || !filledOpenClaw}
|
||||
className={tab === "openclaw" && filledOpenClaw ? "" : "hidden"}
|
||||
>
|
||||
{filledOpenClaw && (
|
||||
<SnippetBlock
|
||||
value={filledOpenClaw}
|
||||
label="OpenClaw MCP config — wires the molecule MCP server via openclaw mcp set + starts the gateway on loopback. Outbound tools today; inbound A2A push on an external openclaw needs the Python SDK tab paired in (a sessions.steer bridge daemon is future work)."
|
||||
copyKey="openclaw"
|
||||
copied={copiedKey === "openclaw"}
|
||||
onCopy={() => copy(filledOpenClaw, "openclaw")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* Kimi tab */}
|
||||
<div
|
||||
id="panel-kimi"
|
||||
data-testid="panel-kimi"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-kimi"
|
||||
hidden={tab !== "kimi" || !filledKimi}
|
||||
className={tab === "kimi" && filledKimi ? "" : "hidden"}
|
||||
>
|
||||
{filledKimi && (
|
||||
<SnippetBlock
|
||||
value={filledKimi}
|
||||
label="Kimi CLI — self-contained Python bridge. Registers, heartbeats, polls for canvas messages, and echoes replies back. NAT-safe (no public URL). Run in a background terminal or via launchd."
|
||||
copyKey="kimi"
|
||||
copied={copiedKey === "kimi"}
|
||||
onCopy={() => copy(filledKimi, "kimi")}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{/* Fields tab */}
|
||||
<div
|
||||
id="panel-fields"
|
||||
data-testid="panel-fields"
|
||||
role="tabpanel"
|
||||
aria-labelledby="tab-fields"
|
||||
hidden={tab !== "fields"}
|
||||
className={tab === "fields" ? "" : "hidden"}
|
||||
>
|
||||
)}
|
||||
{tab === "mcp" && filledUniversalMcp && (
|
||||
<SnippetBlock
|
||||
value={filledUniversalMcp}
|
||||
label="Universal MCP — standalone register + heartbeat + tools for any MCP-aware runtime (Claude Code, hermes, codex). Pair with Python or Claude Code tab if you need inbound A2A delivery."
|
||||
copyKey="mcp"
|
||||
copied={copiedKey === "mcp"}
|
||||
onCopy={() => copy(filledUniversalMcp, "mcp")}
|
||||
/>
|
||||
)}
|
||||
{tab === "hermes" && filledHermes && (
|
||||
<SnippetBlock
|
||||
value={filledHermes}
|
||||
label="Hermes channel — bridges this workspace's A2A traffic into your hermes-agent session as platform messages (push parity with Claude Code). Long-poll based; no tunnel needed."
|
||||
copyKey="hermes"
|
||||
copied={copiedKey === "hermes"}
|
||||
onCopy={() => copy(filledHermes, "hermes")}
|
||||
/>
|
||||
)}
|
||||
{tab === "codex" && filledCodex && (
|
||||
<SnippetBlock
|
||||
value={filledCodex}
|
||||
label="Codex MCP config — wires the molecule MCP server into ~/.codex/config.toml. Outbound tools today; inbound A2A push needs the Python SDK tab paired in (codex's MCP runtime doesn't route arbitrary notifications/* yet)."
|
||||
copyKey="codex"
|
||||
copied={copiedKey === "codex"}
|
||||
onCopy={() => copy(filledCodex, "codex")}
|
||||
/>
|
||||
)}
|
||||
{tab === "openclaw" && filledOpenClaw && (
|
||||
<SnippetBlock
|
||||
value={filledOpenClaw}
|
||||
label="OpenClaw MCP config — wires the molecule MCP server via openclaw mcp set + starts the gateway on loopback. Outbound tools today; inbound A2A push on an external openclaw needs the Python SDK tab paired in (a sessions.steer bridge daemon is future work)."
|
||||
copyKey="openclaw"
|
||||
copied={copiedKey === "openclaw"}
|
||||
onCopy={() => copy(filledOpenClaw, "openclaw")}
|
||||
/>
|
||||
)}
|
||||
{tab === "kimi" && filledKimi && (
|
||||
<SnippetBlock
|
||||
value={filledKimi}
|
||||
label="Kimi CLI — self-contained Python bridge. Registers, heartbeats, polls for canvas messages, and echoes replies back. NAT-safe (no public URL). Run in a background terminal or via launchd."
|
||||
copyKey="kimi"
|
||||
copied={copiedKey === "kimi"}
|
||||
onCopy={() => copy(filledKimi, "kimi")}
|
||||
/>
|
||||
)}
|
||||
{tab === "fields" && (
|
||||
<div className="space-y-2">
|
||||
<Field label="workspace_id" value={info.workspace_id} onCopy={() => copy(info.workspace_id, "wsid")} copied={copiedKey === "wsid"} />
|
||||
<Field label="platform_url" value={info.platform_url} onCopy={() => copy(info.platform_url, "url")} copied={copiedKey === "url"} />
|
||||
@@ -434,7 +323,7 @@ export function ExternalConnectModal({ info, onClose }: Props) {
|
||||
<Field label="registry_endpoint" value={info.registry_endpoint} onCopy={() => copy(info.registry_endpoint, "reg")} copied={copiedKey === "reg"} />
|
||||
<Field label="heartbeat_endpoint" value={info.heartbeat_endpoint} onCopy={() => copy(info.heartbeat_endpoint, "hb")} copied={copiedKey === "hb"} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
|
||||
@@ -440,7 +440,6 @@ function ProviderPickerModal({
|
||||
onChange={(e) => updateEntry(index, { value: e.target.value.trimStart() })}
|
||||
placeholder={entry.key.includes("API_KEY") ? "sk-..." : "Enter value"}
|
||||
type="password"
|
||||
aria-label={`Value for ${entry.key}`}
|
||||
ref={index === 0 ? firstInputRef : undefined}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && entry.value.trim()) {
|
||||
@@ -460,7 +459,7 @@ function ProviderPickerModal({
|
||||
)}
|
||||
|
||||
{entry.error && (
|
||||
<div role="alert" aria-live="assertive" className="mt-1.5 text-[10px] text-bad">{entry.error}</div>
|
||||
<div className="mt-1.5 text-[10px] text-bad">{entry.error}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
@@ -472,7 +471,7 @@ function ProviderPickerModal({
|
||||
{onOpenSettings && (
|
||||
<button
|
||||
onClick={onOpenSettings}
|
||||
className="text-[11px] text-accent hover:text-accent transition-colors"
|
||||
className="text-[11px] text-accent hover:text-accent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
>
|
||||
Open Settings Panel
|
||||
</button>
|
||||
@@ -481,7 +480,7 @@ function ProviderPickerModal({
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-3.5 py-1.5 text-[12px] text-ink-mid hover:text-ink bg-surface-card hover:bg-surface-card border border-line rounded-lg transition-colors"
|
||||
className="px-3.5 py-1.5 text-[12px] text-ink-mid hover:text-ink bg-surface-card hover:bg-surface-card border border-line rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
>
|
||||
Cancel Deploy
|
||||
</button>
|
||||
@@ -695,7 +694,6 @@ function AllKeysModal({
|
||||
onChange={(e) => updateEntry(index, { value: e.target.value.trimStart() })}
|
||||
placeholder={entry.key.includes("API_KEY") ? "sk-..." : "Enter value"}
|
||||
type="password"
|
||||
aria-label={`Value for ${entry.key}`}
|
||||
autoFocus={index === 0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && entry.value.trim()) {
|
||||
@@ -720,7 +718,7 @@ function AllKeysModal({
|
||||
))}
|
||||
|
||||
{globalError && (
|
||||
<div role="alert" aria-live="assertive" className="px-3 py-2 bg-red-950/40 border border-red-800/50 rounded-lg text-[11px] text-bad">
|
||||
<div className="px-3 py-2 bg-red-950/40 border border-red-800/50 rounded-lg text-[11px] text-bad">
|
||||
{globalError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -71,7 +71,7 @@ export function WorkspaceUsage({ workspaceId }: WorkspaceUsageProps) {
|
||||
<SkeletonRow />
|
||||
</>
|
||||
) : error ? (
|
||||
<p role="alert" aria-live="assertive" className="text-xs text-bad" data-testid="usage-error">
|
||||
<p className="text-xs text-bad" data-testid="usage-error">
|
||||
{error}
|
||||
</p>
|
||||
) : metrics ? (
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for BroadcastBanner component.
|
||||
* WCAG compliance: role=alert, aria-live=polite, per-message dismiss.
|
||||
*/
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||
import { render, screen, cleanup, fireEvent } from "@testing-library/react";
|
||||
import { BroadcastBanner } from "../BroadcastBanner";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
|
||||
const mockDismiss = vi.fn();
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
useCanvasStore: vi.fn((selector: (s: ReturnType<typeof useCanvasStore.getState>) => unknown) => {
|
||||
const state = {
|
||||
broadcastMessages: [] as Array<{
|
||||
id: string;
|
||||
senderId: string;
|
||||
sender: string;
|
||||
message: string;
|
||||
timestamp: string;
|
||||
}>,
|
||||
dismissBroadcastMessage: mockDismiss,
|
||||
};
|
||||
return selector(state);
|
||||
}),
|
||||
}));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
mockDismiss.mockClear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
const broadcastMessages = [
|
||||
{ id: "m1", senderId: "ws-ops", sender: "Ops Agent", message: "Deploy in 5 min", timestamp: "2026-05-16T00:00:00Z" },
|
||||
{ id: "m2", senderId: "ws-sre", sender: "SRE Team", message: "Maintenance window tonight", timestamp: "2026-05-16T00:01:00Z" },
|
||||
];
|
||||
|
||||
function setup(messages = broadcastMessages) {
|
||||
vi.mocked(useCanvasStore).mockImplementation(
|
||||
(selector: (s: { broadcastMessages: typeof broadcastMessages; dismissBroadcastMessage: typeof mockDismiss }) => unknown) => {
|
||||
const state = {
|
||||
broadcastMessages: messages,
|
||||
dismissBroadcastMessage: mockDismiss,
|
||||
};
|
||||
return selector(state);
|
||||
}
|
||||
);
|
||||
return render(<BroadcastBanner />);
|
||||
}
|
||||
|
||||
describe("BroadcastBanner", () => {
|
||||
it("renders nothing when there are no messages", () => {
|
||||
setup([]);
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders a role=alert banner for each broadcast message", () => {
|
||||
setup();
|
||||
const alerts = screen.getAllByRole("alert");
|
||||
expect(alerts).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("shows sender name and message content", () => {
|
||||
setup();
|
||||
expect(screen.getByText("Deploy in 5 min")).toBeTruthy();
|
||||
expect(screen.getByText("Ops Agent")).toBeTruthy();
|
||||
expect(screen.getByText("Maintenance window tonight")).toBeTruthy();
|
||||
expect(screen.getByText("SRE Team")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("each banner has a dismiss button with accessible label", () => {
|
||||
setup();
|
||||
const buttons = screen.getAllByRole("button", { name: /dismiss/i });
|
||||
expect(buttons).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("dismissing a banner calls dismissBroadcastMessage with the correct id", () => {
|
||||
setup();
|
||||
const buttons = screen.getAllByRole("button", { name: /dismiss/i });
|
||||
// Dismiss the second message (Maintenance window)
|
||||
fireEvent.click(buttons[1]);
|
||||
expect(mockDismiss).toHaveBeenCalledTimes(1);
|
||||
expect(mockDismiss).toHaveBeenCalledWith("m2");
|
||||
});
|
||||
|
||||
it("dismissing one banner does not dismiss others", () => {
|
||||
setup();
|
||||
const buttons = screen.getAllByRole("button", { name: /dismiss/i });
|
||||
fireEvent.click(buttons[0]);
|
||||
expect(mockDismiss).toHaveBeenCalledWith("m1");
|
||||
expect(mockDismiss).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("dismiss button has focus-visible ring (WCAG 2.4.7)", () => {
|
||||
setup();
|
||||
const button = screen.getAllByRole("button", { name: /dismiss/i })[0];
|
||||
expect(button.className).toContain("focus-visible:ring");
|
||||
});
|
||||
|
||||
it("sender and message text use adequate contrast color classes", () => {
|
||||
setup();
|
||||
// text-blue-300 (#93C5FD) on blue-950/80 ≈ 5.9:1 contrast — WCAG AA ✓
|
||||
const senderLabel = screen.getByText("Ops Agent").closest("div");
|
||||
expect(senderLabel?.className).toContain("text-blue-300");
|
||||
// text-blue-50 (#EFF6FF) on blue-950/80 ≈ 11.7:1 — WCAG AAA ✓
|
||||
const messageEl = screen.getByText("Deploy in 5 min");
|
||||
expect(messageEl.className).toContain("text-blue-50");
|
||||
});
|
||||
});
|
||||
@@ -73,6 +73,8 @@ const mockStoreState = {
|
||||
clearSelection: vi.fn(),
|
||||
toggleNodeSelection: vi.fn(),
|
||||
deletingIds: new Set<string>(),
|
||||
broadcastMessages: [],
|
||||
consumeBroadcastMessages: vi.fn(() => []),
|
||||
};
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
@@ -100,6 +102,7 @@ vi.mock("../ConfirmDialog", () => ({ ConfirmDialog: () => null }));
|
||||
vi.mock("../TemplatePalette", () => ({ TemplatePalette: () => null }));
|
||||
vi.mock("../OnboardingWizard", () => ({ OnboardingWizard: () => null }));
|
||||
vi.mock("../ApprovalBanner", () => ({ ApprovalBanner: () => null }));
|
||||
vi.mock("../BroadcastBanner", () => ({ BroadcastBanner: () => null }));
|
||||
vi.mock("../BundleDropZone", () => ({ BundleDropZone: () => null }));
|
||||
vi.mock("../CreateWorkspaceDialog", () => ({ CreateWorkspaceButton: () => null }));
|
||||
vi.mock("../settings", () => ({
|
||||
|
||||
@@ -91,6 +91,8 @@ const mockStoreState = {
|
||||
// an empty Set mirrors the idle canvas and doesn't interact with
|
||||
// any pan/fit behaviour under test here.
|
||||
deletingIds: new Set<string>(),
|
||||
broadcastMessages: [],
|
||||
consumeBroadcastMessages: vi.fn(() => []),
|
||||
};
|
||||
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
@@ -117,6 +119,7 @@ vi.mock("../ConfirmDialog", () => ({ ConfirmDialog: () => null }));
|
||||
vi.mock("../TemplatePalette", () => ({ TemplatePalette: () => null }));
|
||||
vi.mock("../OnboardingWizard", () => ({ OnboardingWizard: () => null }));
|
||||
vi.mock("../ApprovalBanner", () => ({ ApprovalBanner: () => null }));
|
||||
vi.mock("../BroadcastBanner", () => ({ BroadcastBanner: () => null }));
|
||||
vi.mock("../BundleDropZone", () => ({ BundleDropZone: () => null }));
|
||||
vi.mock("../CreateWorkspaceDialog", () => ({ CreateWorkspaceButton: () => null }));
|
||||
vi.mock("../settings", () => ({
|
||||
|
||||
@@ -131,9 +131,7 @@ describe("ExternalConnectModal — tab switching", () => {
|
||||
it("switches to the Python SDK tab and shows the snippet with stamped token", () => {
|
||||
renderAndFlush(defaultInfo);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /python sdk/i }));
|
||||
// Query within the python panel so we get the right pre (not the first in DOM).
|
||||
const pythonPanel = document.querySelector("[data-testid='panel-python']");
|
||||
const preEl = pythonPanel?.querySelector("pre");
|
||||
const preEl = document.querySelector("pre");
|
||||
expect(preEl?.textContent).toContain("AUTH_TOKEN");
|
||||
// The placeholder is replaced with the real auth token
|
||||
expect(preEl?.textContent).toContain("secret-auth-token-abc");
|
||||
@@ -142,9 +140,7 @@ describe("ExternalConnectModal — tab switching", () => {
|
||||
it("switches to the curl tab and shows the snippet with stamped token", () => {
|
||||
renderAndFlush(defaultInfo);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /curl/i }));
|
||||
// Query within the curl panel so we get the right pre (not the first in DOM).
|
||||
const curlPanel = document.querySelector("[data-testid='panel-curl']");
|
||||
const preEl = curlPanel?.querySelector("pre");
|
||||
const preEl = document.querySelector("pre");
|
||||
expect(preEl?.textContent).toContain("curl");
|
||||
expect(preEl?.textContent).toContain("secret-auth-token-abc");
|
||||
});
|
||||
@@ -152,11 +148,9 @@ describe("ExternalConnectModal — tab switching", () => {
|
||||
it("switches to the Fields tab and shows raw values", () => {
|
||||
renderAndFlush(defaultInfo);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /fields/i }));
|
||||
// Query within the fields panel for specific values.
|
||||
const fieldsPanel = document.querySelector("[data-testid='panel-fields']");
|
||||
expect(fieldsPanel?.textContent).toContain("ws-123");
|
||||
expect(fieldsPanel?.textContent).toContain("https://app.example.com");
|
||||
expect(fieldsPanel?.textContent).toContain("secret-auth-token-abc");
|
||||
expect(screen.getByText("ws-123")).toBeTruthy();
|
||||
expect(screen.getByText("https://app.example.com")).toBeTruthy();
|
||||
expect(screen.getByText("secret-auth-token-abc")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides the Hermes tab when hermes_channel_snippet is absent", () => {
|
||||
@@ -174,8 +168,7 @@ describe("ExternalConnectModal — snippet token stamping", () => {
|
||||
it("stamps the real auth_token into the Python snippet instead of the placeholder", () => {
|
||||
renderAndFlush(defaultInfo);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /python sdk/i }));
|
||||
const pythonPanel = document.querySelector("[data-testid='panel-python']");
|
||||
const preEl = pythonPanel?.querySelector("pre");
|
||||
const preEl = document.querySelector("pre");
|
||||
expect(preEl?.textContent).not.toContain("<paste from create response>");
|
||||
expect(preEl?.textContent).toContain("secret-auth-token-abc");
|
||||
});
|
||||
@@ -183,8 +176,7 @@ describe("ExternalConnectModal — snippet token stamping", () => {
|
||||
it("stamps the real auth_token into the curl snippet", () => {
|
||||
renderAndFlush(defaultInfo);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /curl/i }));
|
||||
const curlPanel = document.querySelector("[data-testid='panel-curl']");
|
||||
const preEl = curlPanel?.querySelector("pre");
|
||||
const preEl = document.querySelector("pre");
|
||||
// curl template uses WORKSPACE_AUTH_TOKEN placeholder, not the generic one
|
||||
expect(preEl?.textContent).toContain("secret-auth-token-abc");
|
||||
});
|
||||
@@ -192,8 +184,7 @@ describe("ExternalConnectModal — snippet token stamping", () => {
|
||||
it("stamps the real auth_token into the Universal MCP snippet", () => {
|
||||
renderAndFlush(defaultInfo);
|
||||
// Default tab is Universal MCP
|
||||
const mcpPanel = document.querySelector("[data-testid='panel-mcp']");
|
||||
const preEl = mcpPanel?.querySelector("pre");
|
||||
const preEl = document.querySelector("pre");
|
||||
expect(preEl?.textContent).toContain("secret-auth-token-abc");
|
||||
expect(preEl?.textContent).not.toContain("<paste from create response>");
|
||||
});
|
||||
@@ -202,10 +193,8 @@ describe("ExternalConnectModal — snippet token stamping", () => {
|
||||
describe("ExternalConnectModal — copy functionality", () => {
|
||||
it("calls navigator.clipboard.writeText with the snippet text", () => {
|
||||
renderAndFlush(defaultInfo);
|
||||
// Default tab is Universal MCP — query the copy button within the mcp panel.
|
||||
const mcpPanel = document.querySelector("[data-testid='panel-mcp']");
|
||||
const copyBtn = mcpPanel?.querySelector("button");
|
||||
if (copyBtn) fireEvent.click(copyBtn);
|
||||
// Default tab is Universal MCP
|
||||
fireEvent.click(screen.getByRole("button", { name: /^copy$/i }));
|
||||
expect(clipboardWriteText).toHaveBeenCalledWith(
|
||||
expect.stringContaining("secret-auth-token-abc"),
|
||||
);
|
||||
@@ -238,8 +227,7 @@ describe("ExternalConnectModal — missing optional fields", () => {
|
||||
};
|
||||
renderAndFlush(minimalInfo);
|
||||
fireEvent.click(screen.getByRole("tab", { name: /fields/i }));
|
||||
const fieldsPanel = document.querySelector("[data-testid='panel-fields']");
|
||||
expect(fieldsPanel?.textContent).toContain("(missing)");
|
||||
expect(screen.getByText("(missing)")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides the Hermes tab when hermes_channel_snippet is absent", () => {
|
||||
|
||||
@@ -11,21 +11,13 @@ import { render, screen, fireEvent, cleanup, act } from "@testing-library/react"
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { TestConnectionButton } from "../ui/TestConnectionButton";
|
||||
import type { SecretGroup } from "@/types/secrets";
|
||||
import { validateSecret, ApiError } from "@/lib/api/secrets";
|
||||
import { validateSecret } from "@/lib/api/secrets";
|
||||
|
||||
// ─── Mock validateSecret ──────────────────────────────────────────────────────
|
||||
// vi.mock is hoisted, so validateSecret (imported above) refers to the mocked
|
||||
// namespace value once vi.mock runs. Use vi.mocked() to access it in tests.
|
||||
vi.mock("@/lib/api/secrets", () => ({
|
||||
validateSecret: vi.fn(),
|
||||
ApiError: class ApiError extends Error {
|
||||
status: number;
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
// SecretGroup is a string literal type: 'github' | 'anthropic' | 'openrouter' | 'custom'
|
||||
@@ -110,7 +102,7 @@ describe("TestConnectionButton — state machine", () => {
|
||||
expect(screen.getByText("Permission denied")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows a connectivity message on a genuine network exception", async () => {
|
||||
it("shows generic error message on unexpected exception", async () => {
|
||||
vi.mocked(validateSecret).mockRejectedValue(new Error("timeout"));
|
||||
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-..." />);
|
||||
|
||||
@@ -118,23 +110,8 @@ describe("TestConnectionButton — state machine", () => {
|
||||
await act(async () => { /* flush */ });
|
||||
|
||||
expect(screen.getByRole("alert")).toBeTruthy();
|
||||
// A real thrown network error → honest connectivity message (not a
|
||||
// fabricated "service down"); see internal#492.
|
||||
expect(document.body.querySelector('[role="alert"]')?.textContent).toMatch(
|
||||
/could not reach the validation service/i,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not claim a timeout when the validate endpoint 404s (internal#492)", async () => {
|
||||
vi.mocked(validateSecret).mockRejectedValue(new ApiError(404, "Not Found"));
|
||||
render(<TestConnectionButton provider={toGroup("anthropic")} secretValue="sk-..." />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
await act(async () => { /* flush */ });
|
||||
|
||||
const alert = document.body.querySelector('[role="alert"]')?.textContent ?? "";
|
||||
expect(alert).not.toMatch(/timed out/i);
|
||||
expect(alert).toMatch(/not available/i);
|
||||
// The error detail is hardcoded to "Connection timed out. Service may be down."
|
||||
expect(document.body.querySelector('[role="alert"]')?.textContent).toMatch(/timed out/i);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -24,12 +24,8 @@ vi.mock("@/lib/theme-provider", () => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
// Wrap cleanup in act() so any pending React state updates (e.g. from
|
||||
// keyDown handlers that call setTheme) flush before DOM unmount. Without
|
||||
// this, cleanup() can race against pending renders and cause INDEX_SIZE_ERR
|
||||
// when the handleKeyDown callback tries to query the DOM mid-teardown.
|
||||
afterEach(() => {
|
||||
act(() => { cleanup(); });
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
@@ -150,7 +146,7 @@ describe("ThemeToggle — keyboard navigation (WCAG 2.1.1 / ARIA radiogroup)", (
|
||||
const radios = screen.getAllByRole("radio");
|
||||
// dark (index 2) is current; ArrowRight should wrap to light (index 0)
|
||||
act(() => { radios[2].focus(); });
|
||||
act(() => { fireEvent.keyDown(radios[2], { key: "ArrowRight" }); });
|
||||
fireEvent.keyDown(radios[2], { key: "ArrowRight" });
|
||||
expect(mockSetTheme).toHaveBeenCalledWith("light");
|
||||
});
|
||||
|
||||
@@ -164,7 +160,7 @@ describe("ThemeToggle — keyboard navigation (WCAG 2.1.1 / ARIA radiogroup)", (
|
||||
const radios = screen.getAllByRole("radio");
|
||||
// light (index 0) is current; ArrowLeft should go to dark (index 2)
|
||||
act(() => { radios[0].focus(); });
|
||||
act(() => { fireEvent.keyDown(radios[0], { key: "ArrowLeft" }); });
|
||||
fireEvent.keyDown(radios[0], { key: "ArrowLeft" });
|
||||
expect(mockSetTheme).toHaveBeenCalledWith("dark");
|
||||
});
|
||||
|
||||
@@ -178,7 +174,7 @@ describe("ThemeToggle — keyboard navigation (WCAG 2.1.1 / ARIA radiogroup)", (
|
||||
const radios = screen.getAllByRole("radio");
|
||||
// light (index 0) is current; ArrowDown should go to system (index 1)
|
||||
act(() => { radios[0].focus(); });
|
||||
act(() => { fireEvent.keyDown(radios[0], { key: "ArrowDown" }); });
|
||||
fireEvent.keyDown(radios[0], { key: "ArrowDown" });
|
||||
expect(mockSetTheme).toHaveBeenCalledWith("system");
|
||||
});
|
||||
|
||||
@@ -191,7 +187,7 @@ describe("ThemeToggle — keyboard navigation (WCAG 2.1.1 / ARIA radiogroup)", (
|
||||
render(<ThemeToggle />);
|
||||
const radios = screen.getAllByRole("radio");
|
||||
act(() => { radios[2].focus(); });
|
||||
act(() => { fireEvent.keyDown(radios[2], { key: "Home" }); });
|
||||
fireEvent.keyDown(radios[2], { key: "Home" });
|
||||
expect(mockSetTheme).toHaveBeenCalledWith("light");
|
||||
});
|
||||
|
||||
@@ -204,14 +200,14 @@ describe("ThemeToggle — keyboard navigation (WCAG 2.1.1 / ARIA radiogroup)", (
|
||||
render(<ThemeToggle />);
|
||||
const radios = screen.getAllByRole("radio");
|
||||
act(() => { radios[0].focus(); });
|
||||
act(() => { fireEvent.keyDown(radios[0], { key: "End" }); });
|
||||
fireEvent.keyDown(radios[0], { key: "End" });
|
||||
expect(mockSetTheme).toHaveBeenCalledWith("dark");
|
||||
});
|
||||
|
||||
it("does nothing on unrelated keys", () => {
|
||||
render(<ThemeToggle />);
|
||||
const radios = screen.getAllByRole("radio");
|
||||
act(() => { fireEvent.keyDown(radios[0], { key: "Enter" }); });
|
||||
fireEvent.keyDown(radios[0], { key: "Enter" });
|
||||
expect(mockSetTheme).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -195,6 +195,47 @@ describe("DropTargetBadge — renders ghost slot + badge for valid drag target",
|
||||
expect(screen.getByTestId("ghost-slot").style.height).toBe("260px");
|
||||
});
|
||||
|
||||
it("ghost has aria-hidden=true (decorative visual affordance)", () => {
|
||||
_getInternalNode.mockReturnValue({
|
||||
internals: { positionAbsolute: { x: 100, y: 200 } },
|
||||
measured: { width: 220, height: 500 },
|
||||
});
|
||||
setFlowMock(({ x, y }: { x: number; y: number }) => {
|
||||
if (x === 210 && y === 200) return { x: 420, y: 400 };
|
||||
if (x === 116 && y === 330) return { x: 232, y: 660 };
|
||||
if (x === 356 && y === 460) return { x: 712, y: 920 };
|
||||
if (x === 100 && y === 200) return { x: 200, y: 400 };
|
||||
if (x === 320 && y === 700) return { x: 640, y: 1400 };
|
||||
return { x: x * 2, y: y * 2 };
|
||||
});
|
||||
|
||||
setStore({
|
||||
dragOverNodeId: "ws-target",
|
||||
nodes: [
|
||||
{ id: "ws-target", data: { name: "Target" }, parentId: null, measured: { width: 220, height: 500 } },
|
||||
],
|
||||
});
|
||||
render(<DropTargetBadge />);
|
||||
const ghost = screen.getByTestId("ghost-slot");
|
||||
expect(ghost.getAttribute("aria-hidden")).toBe("true");
|
||||
});
|
||||
|
||||
it("drop badge has role=status and aria-label including target name", () => {
|
||||
_getInternalNode.mockReturnValue({
|
||||
internals: { positionAbsolute: { x: 100, y: 200 } },
|
||||
measured: { width: 220, height: 120 },
|
||||
});
|
||||
setFlowMock(({ x, y }: { x: number; y: number }) => ({ x: x * 2, y: y * 2 }));
|
||||
setStore({
|
||||
dragOverNodeId: "ws-target",
|
||||
nodes: [{ id: "ws-target", data: { name: "Ops Workspace" }, parentId: null }],
|
||||
});
|
||||
render(<DropTargetBadge />);
|
||||
const badge = screen.getByTestId("drop-badge");
|
||||
expect(badge.getAttribute("role")).toBe("status");
|
||||
expect(badge.getAttribute("aria-label")).toBe("Drop target: Ops Workspace");
|
||||
});
|
||||
|
||||
it("ghost is hidden when slot falls entirely outside parent bounds", () => {
|
||||
_getInternalNode.mockReturnValue({
|
||||
internals: { positionAbsolute: { x: 100, y: 200 } },
|
||||
|
||||
@@ -205,6 +205,7 @@ export function MobileCanvas({
|
||||
type="button"
|
||||
onClick={resetView}
|
||||
aria-label="Reset zoom"
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 14,
|
||||
@@ -223,7 +224,6 @@ export function MobileCanvas({
|
||||
textTransform: "uppercase",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
@@ -273,6 +273,7 @@ export function MobileCanvas({
|
||||
key={l.agent.id}
|
||||
type="button"
|
||||
onClick={() => onOpen(l.agent.id)}
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${l.x}%`,
|
||||
@@ -377,6 +378,7 @@ export function MobileCanvas({
|
||||
type="button"
|
||||
onClick={onSpawn}
|
||||
aria-label="Spawn new agent"
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 24,
|
||||
|
||||
@@ -2,31 +2,25 @@
|
||||
|
||||
// 04 · Chat — message thread + composer + sub-tabs.
|
||||
// Wired to the same /workspaces/:id/a2a (method message/send) endpoint
|
||||
// that the desktop ChatTab uses. Render parity with desktop ChatTab is
|
||||
// achieved by reusing its renderers rather than forking a reduced
|
||||
// mobile path: the Agent Comms sub-tab mounts the same AgentCommsPanel,
|
||||
// and message attachments route through the same AttachmentPreview
|
||||
// dispatch the desktop My-Chat bubble uses (#231/#232).
|
||||
// that the desktop ChatTab uses, but with a slimmer surface: no
|
||||
// attachments, no A2A topology overlay, no conversation tracing.
|
||||
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
import { api } from "@/lib/api";
|
||||
import { useCanvasStore } from "@/store/canvas";
|
||||
import { type ChatAttachment, type ChatMessage, createMessage } from "@/components/tabs/chat/types";
|
||||
import {
|
||||
useChatHistory,
|
||||
useChatSend,
|
||||
useChatSocket,
|
||||
} from "@/components/tabs/chat/hooks";
|
||||
import { AgentCommsPanel } from "@/components/tabs/chat/AgentCommsPanel";
|
||||
import { AttachmentPreview } from "@/components/tabs/chat/AttachmentPreview";
|
||||
import { downloadChatFile } from "@/components/tabs/chat/uploads";
|
||||
|
||||
import { toMobileAgent } from "./components";
|
||||
import { MOBILE_FONT_MONO, MOBILE_FONT_SANS, usePalette } from "./palette";
|
||||
import { Icons, StatusDot, TierChip } from "./primitives";
|
||||
|
||||
interface ChatMessage {
|
||||
id: string;
|
||||
role: "user" | "agent" | "system";
|
||||
text: string;
|
||||
ts: string;
|
||||
}
|
||||
|
||||
const formatStoredTimestamp = (iso: string): string => {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return "";
|
||||
@@ -35,171 +29,29 @@ const formatStoredTimestamp = (iso: string): string => {
|
||||
|
||||
type SubTab = "my" | "a2a";
|
||||
|
||||
function MarkdownBubble({
|
||||
children,
|
||||
dark,
|
||||
accent,
|
||||
}: {
|
||||
children: string;
|
||||
dark: boolean;
|
||||
accent: string;
|
||||
}) {
|
||||
const codeBg = dark ? "rgba(255,255,255,0.08)" : "rgba(0,0,0,0.06)";
|
||||
const codeBlockBg = dark ? "#1a1a1a" : "#f5f5f0";
|
||||
const linkColor = accent;
|
||||
const quoteBorder = dark ? "rgba(255,250,240,0.15)" : "rgba(40,30,20,0.15)";
|
||||
|
||||
return (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
p: ({ children }) => (
|
||||
<div style={{ margin: "2px 0", lineHeight: "inherit" }}>{children}</div>
|
||||
),
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: linkColor, textDecoration: "underline" }}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
pre: ({ children }) => (
|
||||
<pre
|
||||
style={{
|
||||
background: codeBlockBg,
|
||||
padding: "8px 10px",
|
||||
borderRadius: 8,
|
||||
overflow: "auto",
|
||||
fontSize: 12,
|
||||
lineHeight: 1.5,
|
||||
fontFamily: MOBILE_FONT_MONO,
|
||||
margin: "4px 0",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
code: ({ children, className }) => {
|
||||
const isBlock = className != null && String(className).length > 0;
|
||||
if (isBlock) {
|
||||
return (
|
||||
<code style={{ fontFamily: MOBILE_FONT_MONO, fontSize: 12 }}>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<code
|
||||
style={{
|
||||
background: codeBg,
|
||||
padding: "1px 4px",
|
||||
borderRadius: 4,
|
||||
fontSize: 13,
|
||||
fontFamily: MOBILE_FONT_MONO,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
ul: ({ children }) => (
|
||||
<ul style={{ margin: "4px 0", paddingLeft: 18, listStyle: "disc" }}>
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol style={{ margin: "4px 0", paddingLeft: 18, listStyle: "decimal" }}>
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
li: ({ children }) => <li style={{ margin: "2px 0" }}>{children}</li>,
|
||||
strong: ({ children }) => (
|
||||
<strong style={{ fontWeight: 600 }}>{children}</strong>
|
||||
),
|
||||
em: ({ children }) => <em style={{ fontStyle: "italic" }}>{children}</em>,
|
||||
h1: ({ children }) => (
|
||||
<div style={{ fontSize: 16, fontWeight: 700, margin: "4px 0" }}>{children}</div>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<div style={{ fontSize: 15, fontWeight: 700, margin: "4px 0" }}>{children}</div>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<div style={{ fontSize: 14, fontWeight: 700, margin: "4px 0" }}>{children}</div>
|
||||
),
|
||||
h4: ({ children }) => (
|
||||
<div style={{ fontSize: 14, fontWeight: 600, margin: "4px 0" }}>{children}</div>
|
||||
),
|
||||
h5: ({ children }) => (
|
||||
<div style={{ fontSize: 13, fontWeight: 600, margin: "4px 0" }}>{children}</div>
|
||||
),
|
||||
h6: ({ children }) => (
|
||||
<div style={{ fontSize: 13, fontWeight: 600, margin: "4px 0" }}>{children}</div>
|
||||
),
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote
|
||||
style={{
|
||||
borderLeft: `2px solid ${quoteBorder}`,
|
||||
margin: "4px 0",
|
||||
paddingLeft: 8,
|
||||
opacity: 0.85,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
hr: () => (
|
||||
<hr
|
||||
style={{
|
||||
border: "none",
|
||||
borderTop: `0.5px solid ${quoteBorder}`,
|
||||
margin: "6px 0",
|
||||
}}
|
||||
/>
|
||||
),
|
||||
table: ({ children }) => (
|
||||
<table
|
||||
style={{
|
||||
borderCollapse: "collapse",
|
||||
fontSize: 13,
|
||||
margin: "4px 0",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</table>
|
||||
),
|
||||
thead: ({ children }) => <thead style={{ fontWeight: 600 }}>{children}</thead>,
|
||||
th: ({ children }) => (
|
||||
<th
|
||||
style={{
|
||||
border: `0.5px solid ${quoteBorder}`,
|
||||
padding: "4px 6px",
|
||||
textAlign: "left",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children }) => (
|
||||
<td
|
||||
style={{
|
||||
border: `0.5px solid ${quoteBorder}`,
|
||||
padding: "4px 6px",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
),
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
interface A2AResponseShape {
|
||||
result?: {
|
||||
parts?: Array<{ kind?: string; text?: string }>;
|
||||
};
|
||||
error?: { message?: string };
|
||||
}
|
||||
|
||||
// Wire shape for GET /workspaces/:id/chat-history (chat_history.go → ChatHistoryResponse).
|
||||
interface ApiChatMessage {
|
||||
id: string;
|
||||
role: string; // "user" | "agent" | "system"
|
||||
content: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
interface ChatHistoryResponse {
|
||||
messages: ApiChatMessage[];
|
||||
reached_end: boolean;
|
||||
}
|
||||
|
||||
const formatTime = (date: Date) =>
|
||||
date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
|
||||
|
||||
export function MobileChat({
|
||||
agentId,
|
||||
dark,
|
||||
@@ -210,40 +62,31 @@ export function MobileChat({
|
||||
onBack: () => void;
|
||||
}) {
|
||||
const p = usePalette(dark);
|
||||
// Selecting `nodes` stably avoids the `.find()` anti-pattern that
|
||||
// creates a new return value on every store update (React error #185).
|
||||
const nodes = useCanvasStore((s) => s.nodes);
|
||||
const node = useMemo(() => nodes.find((n) => n.id === agentId), [nodes, agentId]);
|
||||
// Bootstrap from the canvas store's per-workspace message buffer so the
|
||||
// user sees their prior thread on entry. The store is updated by the
|
||||
// socket → ChatTab flows the desktop runs; on mobile we read from the
|
||||
// same buffer to keep state coherent across viewports.
|
||||
// NOTE: selector returns undefined (stable) — do NOT use ?? [] here,
|
||||
// that creates a new [] reference on every store update when the key is
|
||||
// absent, causing infinite re-render (React error #185).
|
||||
const storedMessages = useCanvasStore((s) => s.agentMessages[agentId]);
|
||||
// Start empty — history is loaded via useEffect below.
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [tab, setTab] = useState<SubTab>("my");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true); // history is loading on mount
|
||||
const [historyError, setHistoryError] = useState<string | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
// Guard: don't treat the initial store population as a live push.
|
||||
// Set to false after the first render completes.
|
||||
const initDoneRef = useRef(false);
|
||||
const composerRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [pendingFiles, setPendingFiles] = useState<File[]>([]);
|
||||
|
||||
const {
|
||||
messages,
|
||||
loading: historyLoading,
|
||||
loadError: historyError,
|
||||
loadInitial,
|
||||
appendMessageDeduped,
|
||||
} = useChatHistory(agentId);
|
||||
|
||||
const {
|
||||
sending,
|
||||
uploading,
|
||||
sendMessage,
|
||||
error: sendError,
|
||||
clearError,
|
||||
releaseSendGuards,
|
||||
} = useChatSend(agentId, {
|
||||
getHistoryMessages: () => messages,
|
||||
onUserMessage: appendMessageDeduped,
|
||||
onAgentMessage: appendMessageDeduped,
|
||||
});
|
||||
|
||||
useChatSocket(agentId, {
|
||||
onAgentMessage: appendMessageDeduped,
|
||||
onSendComplete: releaseSendGuards,
|
||||
});
|
||||
|
||||
// Auto-grow the textarea: reset height to 'auto' so the scrollHeight
|
||||
// shrinks when the user deletes text, then size to scrollHeight up to
|
||||
@@ -256,26 +99,81 @@ export function MobileChat({
|
||||
el.style.height = `${next}px`;
|
||||
}, [draft]);
|
||||
|
||||
// Fetch chat history on mount; keep merging live agentMessages while the
|
||||
// panel is open. InitDoneRef prevents the initial store snapshot from
|
||||
// triggering the live-merge path (the store buffer is populated by
|
||||
// ChatTab on desktop, not on mobile — this effect loads history as the
|
||||
// mobile-native path).
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const mapApiMessage = (m: ApiChatMessage): ChatMessage => ({
|
||||
id: m.id,
|
||||
role: m.role === "user" ? "user" : "agent",
|
||||
text: m.content,
|
||||
ts: formatStoredTimestamp(m.timestamp),
|
||||
});
|
||||
|
||||
const syncLive = () => {
|
||||
const live = useCanvasStore.getState().agentMessages[agentId] ?? [];
|
||||
if (live.length > 0) {
|
||||
setMessages((prev) => {
|
||||
const existingIds = new Set(prev.map((m) => m.id));
|
||||
const newOnes = live
|
||||
.filter((m) => !existingIds.has(m.id))
|
||||
.map((m) => ({
|
||||
id: m.id,
|
||||
role: "agent" as const,
|
||||
text: m.content,
|
||||
ts: formatStoredTimestamp(m.timestamp),
|
||||
}));
|
||||
return newOnes.length > 0 ? [...prev, ...newOnes] : prev;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const bootstrap = async (): Promise<(() => void) | undefined> => {
|
||||
setLoading(true);
|
||||
setHistoryError(null);
|
||||
try {
|
||||
const res = await api.get<ChatHistoryResponse>(
|
||||
`/workspaces/${agentId}/chat-history?limit=50`,
|
||||
);
|
||||
if (cancelled) return;
|
||||
const initial = (res.messages ?? []).map(mapApiMessage);
|
||||
setMessages(initial);
|
||||
// Mark init done BEFORE marking loading=false so any store push
|
||||
// that arrives in the same tick is treated as live, not init.
|
||||
initDoneRef.current = true;
|
||||
setLoading(false);
|
||||
// Subscribe to live pushes after init is complete.
|
||||
syncLive();
|
||||
const unsubscribe = useCanvasStore.subscribe(syncLive);
|
||||
return unsubscribe; // returned for cleanup
|
||||
} catch (e) {
|
||||
if (cancelled) return;
|
||||
setHistoryError(e instanceof Error ? e.message : "Failed to load chat history");
|
||||
setLoading(false);
|
||||
initDoneRef.current = true;
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
let maybeUnsubscribe: (() => void) | undefined;
|
||||
bootstrap().then((fn) => { maybeUnsubscribe = fn; });
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (maybeUnsubscribe) maybeUnsubscribe();
|
||||
};
|
||||
}, [agentId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
// Consume any agent messages that arrived while history was loading.
|
||||
const initialConsumeDoneRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (historyLoading || initialConsumeDoneRef.current) return;
|
||||
initialConsumeDoneRef.current = true;
|
||||
const consume = useCanvasStore.getState().consumeAgentMessages;
|
||||
const msgs = consume(agentId);
|
||||
for (const m of msgs) {
|
||||
appendMessageDeduped(
|
||||
createMessage("agent", m.content, m.attachments),
|
||||
);
|
||||
}
|
||||
}, [historyLoading, agentId, appendMessageDeduped]);
|
||||
|
||||
if (!node) {
|
||||
return (
|
||||
<div
|
||||
@@ -297,38 +195,51 @@ export function MobileChat({
|
||||
const a = toMobileAgent(node);
|
||||
const reachable = a.status === "online" || a.status === "degraded";
|
||||
|
||||
const onFilesPicked = (fileList: FileList | null) => {
|
||||
if (!fileList) return;
|
||||
const picked = Array.from(fileList);
|
||||
setPendingFiles((prev) => {
|
||||
const keyed = new Set(prev.map((f) => `${f.name}:${f.size}`));
|
||||
return [...prev, ...picked.filter((f) => !keyed.has(`${f.name}:${f.size}`))];
|
||||
});
|
||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||||
};
|
||||
|
||||
const removePendingFile = (index: number) =>
|
||||
setPendingFiles((prev) => prev.filter((_, i) => i !== index));
|
||||
|
||||
// Route attachment downloads through the same authenticated helper
|
||||
// the desktop ChatTab uses (downloadChatFile) so platform-scheme
|
||||
// URIs get a real Blob with auth headers instead of about:blank.
|
||||
const downloadAttachment = (att: ChatAttachment) => {
|
||||
downloadChatFile(agentId, att).catch(() => {
|
||||
// AttachmentPreview's own error affordance covers the in-bubble
|
||||
// failure state; matches ChatTab's behaviour of not double-
|
||||
// reporting a download failure.
|
||||
});
|
||||
};
|
||||
|
||||
const send = async () => {
|
||||
const text = draft.trim();
|
||||
if ((!text && pendingFiles.length === 0) || sending || !reachable) return;
|
||||
clearError();
|
||||
if (!text || sending || !reachable) return;
|
||||
setDraft("");
|
||||
const files = pendingFiles;
|
||||
setPendingFiles([]);
|
||||
await sendMessage(text, files);
|
||||
setError(null);
|
||||
setSending(true);
|
||||
const myMsg: ChatMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: "user",
|
||||
text,
|
||||
ts: formatTime(new Date()),
|
||||
};
|
||||
setMessages((m) => [...m, myMsg]);
|
||||
|
||||
try {
|
||||
const res = await api.post<A2AResponseShape>(`/workspaces/${agentId}/a2a`, {
|
||||
method: "message/send",
|
||||
params: {
|
||||
message: {
|
||||
role: "user",
|
||||
messageId: crypto.randomUUID(),
|
||||
parts: [{ kind: "text", text }],
|
||||
},
|
||||
},
|
||||
});
|
||||
const reply =
|
||||
res.result?.parts?.find((part) => part.kind === "text")?.text ?? "";
|
||||
if (reply) {
|
||||
setMessages((m) => [
|
||||
...m,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
role: "agent",
|
||||
text: reply,
|
||||
ts: formatTime(new Date()),
|
||||
},
|
||||
]);
|
||||
} else if (res.error?.message) {
|
||||
setError(res.error.message);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Failed to send");
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -453,19 +364,7 @@ export function MobileChat({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent Comms — reuse the desktop AgentCommsPanel verbatim so
|
||||
mobile renders the identical peer/A2A + delegation feed
|
||||
(history GET + live socket events) instead of a placeholder
|
||||
(#231). The panel owns its own scroll/load/error/empty
|
||||
states, matching ChatTab's agent-comms tabpanel. */}
|
||||
{tab === "a2a" && (
|
||||
<div style={{ flex: 1, minHeight: 0, overflow: "hidden" }}>
|
||||
<AgentCommsPanel workspaceId={agentId} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Messages */}
|
||||
{tab === "my" && (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
style={{
|
||||
@@ -477,12 +376,25 @@ export function MobileChat({
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
{tab === "my" && historyLoading && (
|
||||
<div role="status" aria-live="polite" style={{ padding: "20px 4px", textAlign: "center", color: p.text3, fontSize: 13 }}>
|
||||
Loading chat history…
|
||||
{tab === "a2a" && (
|
||||
<div
|
||||
style={{
|
||||
padding: "20px 4px",
|
||||
textAlign: "center",
|
||||
color: p.text3,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
Agent Comms — peer-to-peer A2A traffic surfaces in the Comms tab.
|
||||
</div>
|
||||
)}
|
||||
{tab === "my" && !historyLoading && historyError && messages.length === 0 && (
|
||||
{tab === "my" && loading && (
|
||||
<div style={{ padding: "20px 4px", textAlign: "center", color: p.text3, fontSize: 13 }}>
|
||||
<div style={{ marginBottom: 6, opacity: 0.6, animation: "spin 1s linear infinite", display: "inline-block", fontSize: 16 }}>⟳</div>
|
||||
<div>Loading chat history…</div>
|
||||
</div>
|
||||
)}
|
||||
{tab === "my" && !loading && historyError && (
|
||||
<div
|
||||
role="alert"
|
||||
style={{
|
||||
@@ -495,11 +407,29 @@ export function MobileChat({
|
||||
<div style={{ marginBottom: 8 }}>Could not load chat history.</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
loadInitial();
|
||||
}}
|
||||
aria-label="Retry loading chat history"
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400"
|
||||
onClick={() => {
|
||||
setLoading(true);
|
||||
setHistoryError(null);
|
||||
api.get(`/workspaces/${agentId}/chat-history?limit=50`).then(
|
||||
(res: unknown) => {
|
||||
const r = res as ChatHistoryResponse;
|
||||
setMessages((r.messages ?? []).map((m) => ({
|
||||
id: m.id,
|
||||
role: m.role === "user" ? "user" : "agent",
|
||||
text: m.content,
|
||||
ts: formatStoredTimestamp(m.timestamp),
|
||||
})));
|
||||
setLoading(false);
|
||||
initDoneRef.current = true;
|
||||
},
|
||||
).catch((e: unknown) => {
|
||||
setHistoryError(e instanceof Error ? e.message : "Failed to load");
|
||||
setLoading(false);
|
||||
initDoneRef.current = true;
|
||||
});
|
||||
}}
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-failed,#ef4444)] focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
style={{
|
||||
padding: "6px 14px",
|
||||
borderRadius: 14,
|
||||
@@ -514,8 +444,8 @@ export function MobileChat({
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{tab === "my" && !historyLoading && !historyError && messages.length === 0 && (
|
||||
<div role="status" aria-live="polite" style={{ padding: "20px 4px", textAlign: "center", color: p.text3, fontSize: 13 }}>
|
||||
{tab === "my" && !loading && !historyError && messages.length === 0 && (
|
||||
<div style={{ padding: "20px 4px", textAlign: "center", color: p.text3, fontSize: 13 }}>
|
||||
Send a message to start chatting.
|
||||
</div>
|
||||
)}
|
||||
@@ -543,31 +473,7 @@ export function MobileChat({
|
||||
overflowWrap: "anywhere",
|
||||
}}
|
||||
>
|
||||
{m.content && (
|
||||
<MarkdownBubble dark={dark} accent={p.accent}>
|
||||
{m.content}
|
||||
</MarkdownBubble>
|
||||
)}
|
||||
{m.attachments && m.attachments.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: 4,
|
||||
marginTop: m.content ? 6 : 0,
|
||||
}}
|
||||
>
|
||||
{m.attachments.map((att, i) => (
|
||||
<AttachmentPreview
|
||||
key={`${m.id}-${i}`}
|
||||
workspaceId={agentId}
|
||||
attachment={att}
|
||||
onDownload={downloadAttachment}
|
||||
tone={mine ? "user" : "agent"}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{m.text}
|
||||
<div
|
||||
style={{
|
||||
fontSize: 10,
|
||||
@@ -576,13 +482,13 @@ export function MobileChat({
|
||||
fontFamily: MOBILE_FONT_MONO,
|
||||
}}
|
||||
>
|
||||
{formatStoredTimestamp(m.timestamp)}
|
||||
{m.ts}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{sendError && (
|
||||
{error && (
|
||||
<div
|
||||
role="alert"
|
||||
style={{
|
||||
@@ -594,17 +500,11 @@ export function MobileChat({
|
||||
fontSize: 12,
|
||||
}}
|
||||
>
|
||||
{sendError}
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer ID + composer belong to My Chat only. The Agent Comms
|
||||
tab is a read-only peer/A2A feed (parity with desktop
|
||||
ChatTab, where the agent-comms tabpanel has no composer). */}
|
||||
{tab === "my" && (
|
||||
<>
|
||||
{/* Footer ID */}
|
||||
<div
|
||||
style={{
|
||||
@@ -631,61 +531,6 @@ export function MobileChat({
|
||||
backdropFilter: "blur(14px)",
|
||||
}}
|
||||
>
|
||||
{pendingFiles.length > 0 && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: 6,
|
||||
marginBottom: 8,
|
||||
paddingLeft: 2,
|
||||
}}
|
||||
>
|
||||
{pendingFiles.map((f, i) => (
|
||||
<div
|
||||
key={`${f.name}:${f.size}`}
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 4,
|
||||
padding: "3px 8px",
|
||||
borderRadius: 10,
|
||||
background: dark ? "#2a2823" : "#ece9e0",
|
||||
fontSize: 12,
|
||||
color: p.text2,
|
||||
maxWidth: "100%",
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
>
|
||||
{f.name}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removePendingFile(i)}
|
||||
aria-label={`Remove ${f.name}`}
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
style={{
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
color: p.text3,
|
||||
cursor: "pointer",
|
||||
fontSize: 12,
|
||||
padding: 0,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
@@ -697,33 +542,22 @@ export function MobileChat({
|
||||
padding: "6px 6px 6px 12px",
|
||||
}}
|
||||
>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
style={{ display: "none" }}
|
||||
onChange={(e) => onFilesPicked(e.target.files)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={!reachable || sending || uploading}
|
||||
aria-label="Attach"
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 999,
|
||||
border: "none",
|
||||
cursor: reachable && !sending && !uploading ? "pointer" : "not-allowed",
|
||||
cursor: "pointer",
|
||||
background: "transparent",
|
||||
color: p.text3,
|
||||
flexShrink: 0,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
opacity: !reachable || sending || uploading ? 0.4 : 1,
|
||||
}}
|
||||
>
|
||||
{Icons.attach({ size: 16 })}
|
||||
@@ -732,7 +566,6 @@ export function MobileChat({
|
||||
ref={composerRef}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
aria-label="Message"
|
||||
onKeyDown={(e) => {
|
||||
// Enter sends; Shift+Enter inserts a newline. Skip when the
|
||||
// IME is composing — pressing Enter to commit a Chinese/
|
||||
@@ -751,17 +584,13 @@ export function MobileChat({
|
||||
placeholder={reachable ? "Send a message…" : `Agent is ${a.status}`}
|
||||
disabled={!reachable}
|
||||
rows={1}
|
||||
className="focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-1"
|
||||
style={{
|
||||
flex: 1,
|
||||
border: "none",
|
||||
outline: "none",
|
||||
background: "transparent",
|
||||
// iOS Safari/PWA zooms the viewport when a focused textarea
|
||||
// has a computed font-size below 16px. 14.5 triggers that
|
||||
// focus-zoom; the page looks broken until the user pinches
|
||||
// back (#224, same class as desktop #1434 / sibling #225).
|
||||
// 16px is the minimum that keeps focus from zooming.
|
||||
fontSize: 16,
|
||||
fontSize: 14.5,
|
||||
lineHeight: 1.4,
|
||||
color: p.text,
|
||||
padding: "6px 0",
|
||||
@@ -775,38 +604,32 @@ export function MobileChat({
|
||||
<button
|
||||
type="button"
|
||||
onClick={send}
|
||||
disabled={(!draft.trim() && pendingFiles.length === 0) || !reachable || sending || uploading}
|
||||
disabled={!draft.trim() || !reachable || sending}
|
||||
aria-label="Send"
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
borderRadius: 999,
|
||||
border: "none",
|
||||
cursor: (draft.trim() || pendingFiles.length === 0) && !sending && !uploading ? "pointer" : "not-allowed",
|
||||
cursor: draft.trim() && !sending ? "pointer" : "not-allowed",
|
||||
flexShrink: 0,
|
||||
background:
|
||||
(draft.trim() || pendingFiles.length > 0) && reachable && !sending && !uploading
|
||||
draft.trim() && reachable && !sending
|
||||
? p.accent
|
||||
: dark
|
||||
? "#2a2823"
|
||||
: "#ece9e0",
|
||||
color: (draft.trim() || pendingFiles.length > 0) && reachable && !sending && !uploading ? "#fff" : p.text3,
|
||||
color: draft.trim() && reachable && !sending ? "#fff" : p.text3,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{uploading ? (
|
||||
<span style={{ fontSize: 10, fontWeight: 600 }}>↑</span>
|
||||
) : (
|
||||
Icons.send({ size: 16 })
|
||||
)}
|
||||
{Icons.send({ size: 16 })}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -218,6 +218,7 @@ export function MobileComms({ dark }: { dark: boolean }) {
|
||||
key={o.id}
|
||||
type="button"
|
||||
onClick={() => setFilter(o.id)}
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
@@ -231,7 +232,6 @@ export function MobileComms({ dark }: { dark: boolean }) {
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
}}
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
>
|
||||
{o.label}
|
||||
<span
|
||||
@@ -252,11 +252,11 @@ export function MobileComms({ dark }: { dark: boolean }) {
|
||||
|
||||
<div style={{ padding: "0 14px", display: "flex", flexDirection: "column", gap: 8 }}>
|
||||
{loading && items.length === 0 ? (
|
||||
<div role="status" aria-live="polite" style={{ padding: "30px 4px", textAlign: "center", color: p.text3, fontSize: 13 }}>
|
||||
<div style={{ padding: "30px 4px", textAlign: "center", color: p.text3, fontSize: 13 }}>
|
||||
Loading recent comms…
|
||||
</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div role="status" aria-live="polite" style={{ padding: "30px 4px", textAlign: "center", color: p.text3, fontSize: 13 }}>
|
||||
<div style={{ padding: "30px 4px", textAlign: "center", color: p.text3, fontSize: 13 }}>
|
||||
No A2A traffic yet.
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -419,8 +419,6 @@ function DetailActivity({ workspaceId, dark }: { workspaceId: string; dark: bool
|
||||
if (items === null) {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
style={{
|
||||
background: p.surface,
|
||||
borderRadius: 16,
|
||||
|
||||
@@ -183,6 +183,7 @@ export function MobileHome({
|
||||
type="button"
|
||||
onClick={onSpawn}
|
||||
aria-label="Spawn new agent"
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 24,
|
||||
@@ -200,7 +201,6 @@ export function MobileHome({
|
||||
justifyContent: "center",
|
||||
boxShadow: "0 8px 24px rgba(40,30,20,0.25), 0 2px 6px rgba(40,30,20,0.15)",
|
||||
}}
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
>
|
||||
{Icons.plus({ size: 22 })}
|
||||
</button>
|
||||
|
||||
@@ -83,6 +83,7 @@ export function MobileMe({
|
||||
type="button"
|
||||
onClick={() => setAccent(c)}
|
||||
aria-label={`Set accent ${c}`}
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
@@ -92,7 +93,6 @@ export function MobileMe({
|
||||
border: on ? `2px solid ${p.text}` : "2px solid transparent",
|
||||
boxShadow: on ? `0 0 0 2px ${p.bg} inset` : "none",
|
||||
}}
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -174,6 +174,7 @@ function SegmentedRow({
|
||||
key={o.id}
|
||||
type="button"
|
||||
onClick={() => onChange(o.id)}
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "10px 8px",
|
||||
@@ -185,7 +186,6 @@ function SegmentedRow({
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
}}
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
>
|
||||
{o.label}
|
||||
</button>
|
||||
|
||||
@@ -171,8 +171,6 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v
|
||||
<div style={{ padding: "0 14px" }}>
|
||||
{loadingTemplates ? (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
style={{
|
||||
padding: "24px 8px",
|
||||
textAlign: "center",
|
||||
@@ -213,12 +211,12 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
aria-label={`Select template: ${t.name} (tier ${t.tier})`}
|
||||
onClick={() => {
|
||||
setTplId(t.id);
|
||||
setTier(tCode);
|
||||
}}
|
||||
aria-label={`Select template: ${t.name} (tier ${t.tier})`}
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
style={{
|
||||
background: on
|
||||
? dark
|
||||
@@ -307,7 +305,6 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v
|
||||
<input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
aria-label="Agent name"
|
||||
placeholder={tplId
|
||||
? (templates.find((t) => t.id === tplId)?.name ?? "agent-name")
|
||||
: "agent-name"}
|
||||
@@ -318,12 +315,7 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v
|
||||
border: `0.5px solid ${p.border}`,
|
||||
borderRadius: 12,
|
||||
fontFamily: MOBILE_FONT_MONO,
|
||||
// iOS Safari/PWA zooms the viewport when a focused input has
|
||||
// a computed font-size below 16px; the layout jumps and the
|
||||
// page looks broken until the user pinches back (#224 / #225,
|
||||
// same class as desktop #1434). 16px is the minimum that
|
||||
// suppresses that focus-zoom.
|
||||
fontSize: 16,
|
||||
fontSize: 13.5,
|
||||
color: p.text,
|
||||
outline: "none",
|
||||
boxSizing: "border-box",
|
||||
@@ -340,9 +332,10 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v
|
||||
<button
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => setTier(t)}
|
||||
aria-label={`Select tier ${t}: ${TIER_LABEL[t]}`}
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
aria-pressed={tier === t}
|
||||
onClick={() => setTier(t)}
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "10px 8px",
|
||||
@@ -388,10 +381,10 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v
|
||||
<div style={{ padding: "20px 14px max(env(safe-area-inset-bottom), 28px)" }}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Spawn agent"
|
||||
onClick={handleSpawn}
|
||||
disabled={busy || !tplId || templates.length === 0}
|
||||
aria-label="Spawn agent"
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: 52,
|
||||
|
||||
@@ -21,14 +21,6 @@ import { MobileChat } from "../MobileChat";
|
||||
vi.mock("@/lib/api");
|
||||
import { api } from "@/lib/api";
|
||||
|
||||
// AgentCommsPanel (mounted by the Agent Comms sub-tab, #231) subscribes
|
||||
// to the global socket via useSocketEvent. Stub it to a no-op so the
|
||||
// panel mounts without the real ReconnectingSocket — the parity tests
|
||||
// only assert the panel renders (vs the old static placeholder).
|
||||
vi.mock("@/hooks/useSocketEvent", () => ({
|
||||
useSocketEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
// ─── Mock store ───────────────────────────────────────────────────────────────
|
||||
|
||||
const mockAgentId = "ws-chat-test";
|
||||
@@ -163,12 +155,6 @@ beforeEach(() => {
|
||||
mockOnBack.mockClear();
|
||||
mockStoreState.nodes = [];
|
||||
mockStoreState.agentMessages = {};
|
||||
// jsdom doesn't implement scrollIntoView. The Agent Comms tab now
|
||||
// mounts AgentCommsPanel (#231), which scrolls its feed to bottom on
|
||||
// arrival; a no-op stub keeps the panel from throwing under jsdom
|
||||
// (same stub AgentCommsPanel's own render test installs).
|
||||
Element.prototype.scrollIntoView =
|
||||
vi.fn() as unknown as Element["scrollIntoView"];
|
||||
// Set up spies on the real api methods. Tests override these per-call.
|
||||
const getSpy = vi.spyOn(api, "get");
|
||||
const postSpy = vi.spyOn(api, "post");
|
||||
@@ -263,20 +249,6 @@ describe("MobileChat — composer", () => {
|
||||
const sendBtn = container.querySelector('[aria-label="Send"]') as HTMLButtonElement;
|
||||
expect(sendBtn.disabled).toBe(true);
|
||||
});
|
||||
|
||||
// Regression #224: the composer textarea must render with font-size
|
||||
// ≥ 16px. iOS Safari and PWAs auto-zoom the viewport when a focused
|
||||
// input has a computed font-size below 16px — the layout jumps and
|
||||
// the page looks broken until the user pinches back. Same class as
|
||||
// desktop #1434 / sibling MobileSpawn #225.
|
||||
it("composer textarea renders at font-size 16px or greater (iOS focus-zoom regression #224)", () => {
|
||||
const { container } = renderChat(mockAgentId);
|
||||
const textarea = container.querySelector("textarea") as HTMLTextAreaElement;
|
||||
expect(textarea).toBeTruthy();
|
||||
const fs = Number.parseFloat(textarea.style.fontSize);
|
||||
expect(Number.isFinite(fs)).toBe(true);
|
||||
expect(fs).toBeGreaterThanOrEqual(16);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Tabs ─────────────────────────────────────────────────────────────────────
|
||||
@@ -386,7 +358,7 @@ describe("MobileChat — chat history", () => {
|
||||
renderChat(mockAgentId);
|
||||
});
|
||||
expect(api.get).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`/workspaces/${mockAgentId}/chat-history`),
|
||||
`/workspaces/${mockAgentId}/chat-history?limit=50`,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -502,146 +474,3 @@ describe("MobileChat — chat history", () => {
|
||||
expect(getSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #232 · Attachment render parity with desktop ChatTab ────────────────────
|
||||
//
|
||||
// Regression for the CTO-reported mobile bug: MobileChat used to render
|
||||
// only m.content (no attachment surface), so files sent/received in a
|
||||
// conversation were invisible on mobile while desktop showed them. The
|
||||
// fix routes m.attachments through the same AttachmentPreview the
|
||||
// desktop ChatTab bubble uses.
|
||||
|
||||
describe("MobileChat — attachment render parity (#232)", () => {
|
||||
beforeEach(() => {
|
||||
mockStoreState.nodes = [onlineNode];
|
||||
});
|
||||
|
||||
it("renders an attachment from a history message via AttachmentPreview", async () => {
|
||||
const getSpy = vi.spyOn(api, "get");
|
||||
// useChatHistory reads { messages, reached_end }.
|
||||
getSpy.mockResolvedValueOnce({
|
||||
messages: [
|
||||
{
|
||||
id: "m-att-1",
|
||||
role: "agent",
|
||||
content: "Here is the report",
|
||||
attachments: [
|
||||
{
|
||||
name: "report.csv",
|
||||
uri: "workspace://out/report.csv",
|
||||
mimeType: "text/csv",
|
||||
size: 2048,
|
||||
},
|
||||
],
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
reached_end: true,
|
||||
});
|
||||
|
||||
let rr: ReturnType<typeof renderChat>;
|
||||
await act(async () => {
|
||||
rr = renderChat(mockAgentId);
|
||||
});
|
||||
const { container } = rr!;
|
||||
|
||||
// A non-image attachment renders the AttachmentChip download button
|
||||
// with title="Download <name>" — same component the desktop bubble
|
||||
// dispatches through AttachmentPreview.
|
||||
await waitFor(() => {
|
||||
const chip = container.querySelector('[title="Download report.csv"]');
|
||||
expect(chip).toBeTruthy();
|
||||
});
|
||||
expect(container.textContent ?? "").toContain("report.csv");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── #231 · Agent Comms (A2A/peer) render parity with desktop ChatTab ────────
|
||||
//
|
||||
// Regression for the CTO-reported mobile bug: the Agent Comms sub-tab
|
||||
// rendered a static placeholder string ("peer-to-peer A2A traffic
|
||||
// surfaces in the Comms tab") instead of the real feed. The fix mounts
|
||||
// the same AgentCommsPanel the desktop ChatTab agent-comms tabpanel
|
||||
// uses, so peer/A2A + delegation activity is visible on mobile.
|
||||
|
||||
describe("MobileChat — Agent Comms render parity (#231)", () => {
|
||||
beforeEach(() => {
|
||||
mockStoreState.nodes = [onlineNode];
|
||||
});
|
||||
|
||||
it("mounts AgentCommsPanel on the Agent Comms tab (not the old placeholder)", async () => {
|
||||
const getSpy = vi.spyOn(api, "get");
|
||||
// 1st GET: useChatHistory (My Chat) on mount.
|
||||
getSpy.mockResolvedValueOnce({ messages: [], reached_end: true });
|
||||
// 2nd GET: AgentCommsPanel's activity load when the tab is shown.
|
||||
// Empty list → panel renders its own empty state, which still
|
||||
// proves AgentCommsPanel mounted (vs. the removed placeholder).
|
||||
getSpy.mockResolvedValueOnce([]);
|
||||
|
||||
let rr: ReturnType<typeof renderChat>;
|
||||
await act(async () => {
|
||||
rr = renderChat(mockAgentId);
|
||||
});
|
||||
const { container } = rr!;
|
||||
|
||||
const commsTab = Array.from(container.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.trim() === "Agent Comms",
|
||||
);
|
||||
expect(commsTab).toBeTruthy();
|
||||
await act(async () => {
|
||||
commsTab!.click();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
const text = container.textContent ?? "";
|
||||
// The panel's empty state — proves AgentCommsPanel mounted.
|
||||
expect(text).toContain("No agent-to-agent communications yet.");
|
||||
});
|
||||
// The old hard-coded placeholder must be gone.
|
||||
expect(container.textContent ?? "").not.toContain(
|
||||
"peer-to-peer A2A traffic surfaces in the Comms tab",
|
||||
);
|
||||
// The panel hit its activity endpoint.
|
||||
expect(getSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`/workspaces/${mockAgentId}/activity`),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders a peer message on the Agent Comms tab", async () => {
|
||||
const getSpy = vi.spyOn(api, "get");
|
||||
getSpy.mockResolvedValueOnce({ messages: [], reached_end: true });
|
||||
// a2a_receive from a peer → AgentCommsPanel.toCommMessage maps it
|
||||
// to an inbound bubble with the request text.
|
||||
getSpy.mockResolvedValueOnce([
|
||||
{
|
||||
id: "act-1",
|
||||
activity_type: "a2a_receive",
|
||||
source_id: "peer-ws-uuid",
|
||||
target_id: mockAgentId,
|
||||
method: "message/send",
|
||||
summary: "peer asked something",
|
||||
request_body: { task: "Please review PR 42" },
|
||||
response_body: null,
|
||||
status: "ok",
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
]);
|
||||
|
||||
let rr: ReturnType<typeof renderChat>;
|
||||
await act(async () => {
|
||||
rr = renderChat(mockAgentId);
|
||||
});
|
||||
const { container } = rr!;
|
||||
|
||||
const commsTab = Array.from(container.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.trim() === "Agent Comms",
|
||||
);
|
||||
await act(async () => {
|
||||
commsTab!.click();
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.textContent ?? "").toContain("Please review PR 42");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -93,24 +93,6 @@ describe("MobileSpawn — render", () => {
|
||||
expect(input).toBeTruthy();
|
||||
});
|
||||
|
||||
// Regression #224 / #225: the agent-name input must render with a
|
||||
// font-size ≥ 16px. iOS Safari and PWAs auto-zoom the viewport when a
|
||||
// focused input has a computed font-size below 16px — the layout
|
||||
// jumps and the page looks broken until the user pinches back.
|
||||
it("renders the name input at font-size 16px or greater (iOS focus-zoom regression)", () => {
|
||||
apiGetSpy.mockResolvedValue(mockTemplates);
|
||||
render(<MobileSpawn dark={true} onClose={vi.fn()} />);
|
||||
const input = document.querySelector(
|
||||
'input[aria-label="Agent name"]',
|
||||
) as HTMLInputElement | null;
|
||||
expect(input).toBeTruthy();
|
||||
// Parse the inline style font-size — jsdom doesn't run a layout
|
||||
// engine, so getComputedStyle reports the inline value verbatim.
|
||||
const fs = Number.parseFloat(input!.style.fontSize);
|
||||
expect(Number.isFinite(fs)).toBe(true);
|
||||
expect(fs).toBeGreaterThanOrEqual(16);
|
||||
});
|
||||
|
||||
it("renders all 4 tier buttons", () => {
|
||||
apiGetSpy.mockResolvedValue(mockTemplates);
|
||||
render(<MobileSpawn dark={true} onClose={vi.fn()} />);
|
||||
|
||||
@@ -133,7 +133,7 @@ export function TabBar({
|
||||
aria-label={t.label}
|
||||
onClick={() => onChange(t.id)}
|
||||
onKeyDown={(e) => handleKeyDown(e, idx)}
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
style={{
|
||||
background: "none",
|
||||
border: "none",
|
||||
@@ -292,7 +292,7 @@ export function AgentCard({
|
||||
data-testid="workspace-card"
|
||||
aria-label={`${agent.name}, status: ${agent.status}, tier ${agent.tier}${agent.remote ? ", remote" : ""}`}
|
||||
onClick={onClick}
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
style={{
|
||||
display: "block",
|
||||
width: "100%",
|
||||
@@ -446,7 +446,7 @@ export function FilterChips({
|
||||
type="button"
|
||||
aria-checked={on}
|
||||
onClick={() => onChange(o.id)}
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-2 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500 focus-visible:ring-offset-1 focus-visible:ring-offset-zinc-100 dark:focus-visible:ring-offset-zinc-900"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -160,14 +160,14 @@ export function OrgTokensTab() {
|
||||
</code>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="shrink-0 px-2 py-1.5 bg-emerald-800/40 hover:bg-emerald-700/50 border border-emerald-700/40 rounded text-[10px] text-good transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
className="shrink-0 px-2 py-1.5 bg-emerald-800/40 hover:bg-emerald-700/50 border border-emerald-700/40 rounded text-[10px] text-good transition-colors"
|
||||
>
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setNewToken(null)}
|
||||
className="text-[9px] text-good/60 hover:text-good transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
className="text-[9px] text-good/60 hover:text-good transition-colors"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
@@ -219,7 +219,7 @@ export function OrgTokensTab() {
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setRevokeTarget(t)}
|
||||
className="text-[10px] text-bad/70 hover:text-bad transition-colors px-2 py-1 shrink-0 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400 focus-visible:ring-offset-1"
|
||||
className="text-[10px] text-bad/70 hover:text-bad transition-colors px-2 py-1 shrink-0"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
|
||||
@@ -3,24 +3,16 @@ import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import type { Secret, SecretGroup } from '@/types/secrets';
|
||||
import { useSecretsStore } from '@/stores/secrets-store';
|
||||
import { StatusBadge } from '@/components/ui/StatusBadge';
|
||||
import { RevealToggle } from '@/components/ui/RevealToggle';
|
||||
import { KeyValueField } from '@/components/ui/KeyValueField';
|
||||
import { ValidationHint } from '@/components/ui/ValidationHint';
|
||||
import { TestConnectionButton } from '@/components/ui/TestConnectionButton';
|
||||
import { validateSecretValue } from '@/lib/validation/secret-formats';
|
||||
import { SERVICES } from '@/lib/services';
|
||||
|
||||
const AUTO_HIDE_MS = 30_000;
|
||||
const VALIDATION_DEBOUNCE_MS = 400;
|
||||
|
||||
// Secret values are write-only from the browser: the server List endpoint
|
||||
// "Never exposes values", there is no per-secret decrypt route, and the
|
||||
// only decrypted path (GET /secrets/values) is bulk + token-gated for
|
||||
// remote agents. The old eye/RevealToggle was a dead affordance — it
|
||||
// flipped its own icon but could never reveal anything, which read as
|
||||
// "this doesn't work" (esp. once clicked → eye-with-slash). We show an
|
||||
// honest static indicator instead; rotation is via Edit.
|
||||
const WRITE_ONLY_TITLE =
|
||||
'Value is write-only and cannot be revealed — use Edit to replace/rotate it';
|
||||
|
||||
interface SecretRowProps {
|
||||
secret: Secret;
|
||||
workspaceId: string;
|
||||
@@ -39,12 +31,28 @@ export function SecretRow({ secret, workspaceId }: SecretRowProps) {
|
||||
const setSecretStatus = useSecretsStore((s) => s.setSecretStatus);
|
||||
|
||||
const isEditing = editingKey === secret.name;
|
||||
const [revealed, setRevealed] = useState(false);
|
||||
const [editValue, setEditValue] = useState('');
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
const editBtnRef = useRef<HTMLButtonElement>(null);
|
||||
const revealTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
||||
|
||||
// Auto-hide revealed value after 30s
|
||||
useEffect(() => {
|
||||
if (revealed) {
|
||||
clearTimeout(revealTimerRef.current);
|
||||
revealTimerRef.current = setTimeout(() => setRevealed(false), AUTO_HIDE_MS);
|
||||
return () => clearTimeout(revealTimerRef.current);
|
||||
}
|
||||
}, [revealed]);
|
||||
|
||||
// Reset revealed state when panel closes (session-only)
|
||||
useEffect(() => {
|
||||
return () => setRevealed(false);
|
||||
}, []);
|
||||
|
||||
// Debounced validation
|
||||
useEffect(() => {
|
||||
@@ -125,15 +133,11 @@ export function SecretRow({ secret, workspaceId }: SecretRowProps) {
|
||||
{secret.masked_value}
|
||||
</span>
|
||||
<div className="secret-row__actions">
|
||||
<span
|
||||
data-testid="write-only-indicator"
|
||||
className="secret-row__write-only"
|
||||
role="img"
|
||||
aria-label={`${secret.name} value is write-only and cannot be revealed; use Edit to replace it`}
|
||||
title={WRITE_ONLY_TITLE}
|
||||
>
|
||||
🔒
|
||||
</span>
|
||||
<RevealToggle
|
||||
revealed={revealed}
|
||||
onToggle={() => setRevealed((r) => !r)}
|
||||
label={`Toggle reveal ${secret.name}`}
|
||||
/>
|
||||
<StatusBadge status={secret.status} />
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -16,40 +16,7 @@ interface TokensTabProps {
|
||||
workspaceId: string;
|
||||
}
|
||||
|
||||
// The settings panel passes the literal sentinel "global" when no canvas
|
||||
// node is selected. Workspace tokens are inherently per-workspace — there
|
||||
// is no /workspaces/global/tokens endpoint (querying the uuid column with
|
||||
// "global" 500s on Postgres). The org-wide equivalent lives in the
|
||||
// separate "Org API Keys" tab. Mirrors the sentinel-awareness that
|
||||
// api/secrets.ts already has (workspaceId === 'global' → /settings/secrets).
|
||||
const GLOBAL_WORKSPACE_ID = 'global';
|
||||
|
||||
export function TokensTab({ workspaceId }: TokensTabProps) {
|
||||
if (workspaceId === GLOBAL_WORKSPACE_ID) {
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-ink">API Tokens</h3>
|
||||
<p className="text-[10px] text-ink-mid mt-0.5">
|
||||
Bearer tokens for authenticating API calls to this workspace.
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-center py-6">
|
||||
<p className="text-xs text-ink-mid">Select a workspace node first</p>
|
||||
<p className="text-[10px] text-ink-mid mt-1">
|
||||
Workspace tokens are scoped to a single workspace. Select a node
|
||||
on the canvas to manage its tokens, or use the{' '}
|
||||
<span className="text-accent font-medium">Org API Keys</span> tab
|
||||
for org-wide API keys.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <WorkspaceTokensTab workspaceId={workspaceId} />;
|
||||
}
|
||||
|
||||
function WorkspaceTokensTab({ workspaceId }: TokensTabProps) {
|
||||
const [tokens, setTokens] = useState<Token[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [creating, setCreating] = useState(false);
|
||||
@@ -140,14 +107,14 @@ function WorkspaceTokensTab({ workspaceId }: TokensTabProps) {
|
||||
</code>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="shrink-0 px-2 py-1.5 bg-emerald-800/40 hover:bg-emerald-700/50 border border-emerald-700/40 rounded text-[10px] text-good transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
className="shrink-0 px-2 py-1.5 bg-emerald-800/40 hover:bg-emerald-700/50 border border-emerald-700/40 rounded text-[10px] text-good transition-colors"
|
||||
>
|
||||
{copied ? 'Copied' : 'Copy'}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setNewToken(null)}
|
||||
className="text-[9px] text-good/60 hover:text-good transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
className="text-[9px] text-good/60 hover:text-good transition-colors"
|
||||
>
|
||||
Dismiss
|
||||
</button>
|
||||
@@ -192,7 +159,7 @@ function WorkspaceTokensTab({ workspaceId }: TokensTabProps) {
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setRevokeTarget(t)}
|
||||
className="text-[10px] text-bad/70 hover:text-bad transition-colors px-2 py-1 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400 focus-visible:ring-offset-1"
|
||||
className="text-[10px] text-bad/70 hover:text-bad transition-colors px-2 py-1"
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
|
||||
@@ -138,54 +138,14 @@ describe("SecretRow — display mode", () => {
|
||||
expect(document.querySelector('[role="row"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it("has Copy, Edit, Delete buttons", () => {
|
||||
it("has Reveal, Copy, Edit, Delete buttons", () => {
|
||||
render(<SecretRow secret={GITHUB_SECRET} workspaceId="ws-1" />);
|
||||
expect(screen.getByTestId("reveal-toggle")).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /copy/i })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /edit/i })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: /delete/i })).toBeTruthy();
|
||||
});
|
||||
|
||||
// Regression: the reveal/eye control was a dead affordance. Clicking it
|
||||
// flipped its own icon (eye → eye-with-slash) but never revealed the value,
|
||||
// because secret values are write-only from the browser (server List
|
||||
// "Never exposes values"; there is no per-secret decrypt endpoint and the
|
||||
// client has no plaintext-fetch function). The honest fix removes the
|
||||
// toggle and shows a static "write-only / cannot be revealed" indicator.
|
||||
// See internal tracking issue + internal#210/#211.
|
||||
it("does NOT render a reveal/eye toggle (values are write-only)", () => {
|
||||
render(<SecretRow secret={GITHUB_SECRET} workspaceId="ws-1" />);
|
||||
expect(screen.queryByTestId("reveal-toggle")).toBeNull();
|
||||
expect(
|
||||
screen.queryByRole("button", { name: /toggle reveal/i }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it("shows a write-only indicator explaining the value cannot be revealed", () => {
|
||||
render(<SecretRow secret={ANTHROPIC_SECRET} workspaceId="ws-1" />);
|
||||
const indicator = screen.getByTestId("write-only-indicator");
|
||||
expect(indicator).toBeTruthy();
|
||||
// Affordance must be honest: explain it cannot be revealed and that
|
||||
// Edit is the rotate path. It must not be a clickable button.
|
||||
const title = indicator.getAttribute("title") ?? "";
|
||||
expect(title.toLowerCase()).toMatch(/write-only|cannot be revealed/);
|
||||
expect(indicator.tagName).not.toBe("BUTTON");
|
||||
});
|
||||
|
||||
it("write-only indicator is present for the Anthropic/OAuth-token row too", () => {
|
||||
// The reported bug singled out CLAUDE_CODE_OAUTH_TOKEN (anthropic group);
|
||||
// the fix is group-agnostic — every row gets the same honest affordance.
|
||||
const OAUTH_SECRET = {
|
||||
name: "CLAUDE_CODE_OAUTH_TOKEN",
|
||||
masked_value: "••••••••••••••••9d2a",
|
||||
group: "anthropic" as const,
|
||||
status: "unverified" as const,
|
||||
updated_at: "2024-01-04",
|
||||
};
|
||||
render(<SecretRow secret={OAUTH_SECRET} workspaceId="ws-1" />);
|
||||
expect(screen.queryByTestId("reveal-toggle")).toBeNull();
|
||||
expect(screen.getByTestId("write-only-indicator")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("shows invalid status correctly", () => {
|
||||
render(<SecretRow secret={CUSTOM_SECRET} workspaceId="ws-1" />);
|
||||
expect(screen.getByTestId("status-badge").getAttribute("data-status")).toBe("invalid");
|
||||
|
||||
@@ -302,35 +302,3 @@ describe("TokensTab — error", () => {
|
||||
expect(document.querySelector('[role="status"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── "global" sentinel (no node selected) ────────────────────────────────────
|
||||
//
|
||||
// Regression: SettingsPanel passes the literal "global" when no canvas
|
||||
// node is selected. workspace tokens are per-workspace and there is no
|
||||
// /workspaces/global/tokens endpoint — calling it 500'd
|
||||
// ("invalid input syntax for type uuid: global"). The tab must NOT call
|
||||
// the API in that state and must point the user at the Org API Keys tab.
|
||||
describe("TokensTab — global sentinel (no node selected)", () => {
|
||||
beforeEach(() => {
|
||||
mockApiGet.mockReset();
|
||||
mockApiPost.mockReset();
|
||||
mockApiGet.mockRejectedValue(new Error("should not be called"));
|
||||
});
|
||||
|
||||
it("does not call the API and shows a pointer to Org API Keys", async () => {
|
||||
render(<TokensTab workspaceId="global" />);
|
||||
await flush();
|
||||
expect(mockApiGet).not.toHaveBeenCalled();
|
||||
expect(mockApiPost).not.toHaveBeenCalled();
|
||||
expect(document.body.textContent).toContain("Select a workspace node");
|
||||
expect(document.body.textContent).toContain("Org API Keys");
|
||||
// No error banner, no scary 500 surfacing.
|
||||
expect(document.querySelector(".text-bad")).toBeNull();
|
||||
});
|
||||
|
||||
it("has no create button in the global state", async () => {
|
||||
render(<TokensTab workspaceId="global" />);
|
||||
await flush();
|
||||
expect(document.body.textContent).not.toContain("New Token");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -139,7 +139,7 @@ export function ActivityTab({ workspaceId }: Props) {
|
||||
key={f.id}
|
||||
onClick={() => setFilter(f.id)}
|
||||
aria-pressed={filter === f.id}
|
||||
className={`px-2 py-1 text-[11px] rounded-md font-medium transition-all ${
|
||||
className={`px-2 py-1 text-[11px] rounded-md font-medium transition-all focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ${
|
||||
filter === f.id
|
||||
? "bg-surface-card text-ink ring-1 ring-zinc-600"
|
||||
: "text-ink-mid hover:text-ink-mid hover:bg-surface-card/60"
|
||||
@@ -152,7 +152,7 @@ export function ActivityTab({ workspaceId }: Props) {
|
||||
<button
|
||||
onClick={() => setAutoRefresh(!autoRefresh)}
|
||||
aria-pressed={autoRefresh}
|
||||
className={`text-[11px] px-1.5 py-0.5 rounded ${
|
||||
className={`text-[11px] px-1.5 py-0.5 rounded focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ${
|
||||
autoRefresh ? "text-good bg-emerald-950/30" : "text-ink-mid"
|
||||
}`}
|
||||
title={autoRefresh ? "Auto-refresh ON" : "Auto-refresh OFF"}
|
||||
@@ -161,8 +161,9 @@ export function ActivityTab({ workspaceId }: Props) {
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTraceOpen(true)}
|
||||
className="px-2 py-1 bg-blue-900/40 hover:bg-blue-800/50 text-[11px] rounded text-accent border border-blue-800/30"
|
||||
title="View full conversation trace across all workspaces"
|
||||
aria-label="Full trace"
|
||||
className="px-2 py-1 bg-blue-900/40 hover:bg-blue-800/50 text-[11px] rounded text-accent border border-blue-800/30 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
title="View full conversation trace"
|
||||
>
|
||||
Full Trace
|
||||
</button>
|
||||
@@ -185,7 +186,7 @@ export function ActivityTab({ workspaceId }: Props) {
|
||||
{/* Activity list */}
|
||||
<div className="flex-1 overflow-y-auto p-3 space-y-1.5">
|
||||
{loading && activities.length === 0 && (
|
||||
<div role="status" aria-live="polite" className="text-xs text-ink-mid text-center py-8">Loading activity...</div>
|
||||
<div className="text-xs text-ink-mid text-center py-8">Loading activity...</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
|
||||
@@ -262,7 +262,7 @@ export function ChannelsTab({ workspaceId }: Props) {
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div role="alert" aria-live="assertive" className="px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-bad">
|
||||
<div className="px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-bad">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
@@ -331,8 +331,9 @@ export function ChannelsTab({ workspaceId }: Props) {
|
||||
</label>
|
||||
))}
|
||||
<button
|
||||
aria-label={showManualInput ? "Hide manual input" : "Show manual input"}
|
||||
onClick={() => setShowManualInput(!showManualInput)}
|
||||
className="text-[10px] text-accent hover:underline"
|
||||
className="text-[10px] text-accent hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
{showManualInput ? "hide manual input" : "edit manually"}
|
||||
</button>
|
||||
@@ -408,15 +409,16 @@ export function ChannelsTab({ workspaceId }: Props) {
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
aria-label={testing === ch.id ? "Sent!" : "Test channel"}
|
||||
onClick={() => handleTest(ch)}
|
||||
disabled={testing === ch.id}
|
||||
className="text-[10px] px-2 py-0.5 rounded bg-surface-card/50 text-ink-mid hover:text-ink transition disabled:opacity-50"
|
||||
className="text-[10px] px-2 py-0.5 rounded bg-surface-card/50 text-ink-mid hover:text-ink transition disabled:opacity-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
{testing === ch.id ? "Sent!" : "Test"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleToggle(ch)}
|
||||
className={`text-[10px] px-2 py-0.5 rounded transition ${
|
||||
className={`text-[10px] px-2 py-0.5 rounded transition focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ${
|
||||
ch.enabled
|
||||
? "bg-emerald-900/30 text-good hover:bg-emerald-900/50"
|
||||
: "bg-surface-card/50 text-ink-mid hover:text-ink-mid"
|
||||
@@ -425,8 +427,9 @@ export function ChannelsTab({ workspaceId }: Props) {
|
||||
{ch.enabled ? "On" : "Off"}
|
||||
</button>
|
||||
<button
|
||||
aria-label={`Remove ${ch.config.chat_id || ch.config.channel_id || "channel"}`}
|
||||
onClick={() => setPendingDelete(ch)}
|
||||
className="text-[10px] px-2 py-0.5 rounded bg-red-900/20 text-bad hover:bg-red-900/40 transition"
|
||||
className="text-[10px] px-2 py-0.5 rounded bg-red-900/20 text-bad hover:bg-red-900/40 transition focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400"
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
|
||||
@@ -383,7 +383,8 @@ function MyChatPanel({ workspaceId, data }: Props) {
|
||||
// ignore — user will see no change and can retry
|
||||
}
|
||||
}}
|
||||
className="px-2 py-0.5 text-[10px] font-medium bg-accent/10 hover:bg-accent/20 text-accent rounded border border-accent/30 transition-colors shrink-0"
|
||||
aria-label="Enable agent chat"
|
||||
className="px-2 py-0.5 text-[10px] font-medium bg-accent/10 hover:bg-accent/20 text-accent rounded border border-accent/30 transition-colors shrink-0 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
Enable
|
||||
</button>
|
||||
@@ -403,8 +404,9 @@ function MyChatPanel({ workspaceId, data }: Props) {
|
||||
Failed to load chat history: {history.loadError}
|
||||
</p>
|
||||
<button
|
||||
aria-label="Retry loading chat history"
|
||||
onClick={history.loadInitial}
|
||||
className="text-[10px] px-2 py-0.5 rounded bg-red-800 text-red-200 hover:bg-red-700 transition-colors"
|
||||
className="text-[10px] px-2 py-0.5 rounded bg-red-800 text-red-200 hover:bg-red-700 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
@@ -599,8 +601,9 @@ function MyChatPanel({ workspaceId, data }: Props) {
|
||||
<span className="text-[10px] text-red-300">{displayError}</span>
|
||||
{!isOnline && (
|
||||
<button
|
||||
aria-label="Restart workspace"
|
||||
onClick={() => setConfirmRestart(true)}
|
||||
className="text-[11px] px-2 py-0.5 bg-red-800 text-red-200 rounded hover:bg-red-700"
|
||||
className="text-[11px] px-2 py-0.5 bg-red-800 text-red-200 rounded hover:bg-red-700 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400"
|
||||
>
|
||||
Restart
|
||||
</button>
|
||||
@@ -636,7 +639,7 @@ function MyChatPanel({ workspaceId, data }: Props) {
|
||||
disabled={!agentReachable || sending || uploading}
|
||||
aria-label="Attach file"
|
||||
title="Attach file"
|
||||
className="p-2 bg-surface-card hover:bg-surface-card border border-line rounded-lg text-ink-mid hover:text-ink transition-colors shrink-0 disabled:opacity-40"
|
||||
className="p-2 bg-surface-card hover:bg-surface-card border border-line rounded-lg text-ink-mid hover:text-ink transition-colors shrink-0 disabled:opacity-40 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path d="M11 6.5 7 10.5a2 2 0 1 0 2.8 2.8l4-4a3.5 3.5 0 0 0-5-5l-4.5 4.5a5 5 0 0 0 7 7l4-4" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" />
|
||||
@@ -674,9 +677,10 @@ function MyChatPanel({ workspaceId, data }: Props) {
|
||||
className="flex-1 bg-surface-card border border-line rounded-lg px-3 py-2 text-xs text-ink placeholder-ink-soft dark:bg-zinc-800 dark:border-zinc-600 dark:placeholder-zinc-500 focus:outline-none focus:border-accent focus-visible:ring-2 focus-visible:ring-accent/40 resize-none disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
aria-label="Send message"
|
||||
onClick={handleSend}
|
||||
disabled={(!input.trim() && pendingFiles.length === 0) || !agentReachable || sending || uploading}
|
||||
className="px-4 py-2 bg-accent-strong hover:bg-accent text-xs font-medium rounded-lg text-white disabled:opacity-30 transition-colors shrink-0"
|
||||
className="px-4 py-2 bg-accent-strong hover:bg-accent text-xs font-medium rounded-lg text-white disabled:opacity-30 transition-colors shrink-0 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
{uploading ? "Uploading…" : "Send"}
|
||||
</button>
|
||||
|
||||
@@ -81,7 +81,7 @@ function AgentCardSection({ workspaceId }: { workspaceId: string }) {
|
||||
spellCheck={false} rows={12}
|
||||
className="w-full bg-surface-card border border-line rounded p-2 text-[10px] font-mono text-ink focus:outline-none focus:border-accent resize-none"
|
||||
/>
|
||||
{error && <div role="alert" aria-live="assertive" className="px-2 py-1 bg-red-900/30 border border-red-800 rounded text-[10px] text-bad">{error}</div>}
|
||||
{error && <div className="px-2 py-1 bg-red-900/30 border border-red-800 rounded text-[10px] text-bad">{error}</div>}
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={handleSave} disabled={saving}
|
||||
className="px-2 py-1 bg-accent hover:bg-accent-strong text-[10px] rounded text-white disabled:opacity-50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1 focus-visible:ring-offset-surface">
|
||||
@@ -109,130 +109,6 @@ function AgentCardSection({ workspaceId }: { workspaceId: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
// --- Agent Abilities Section ---
|
||||
//
|
||||
// Always-visible on/off controls for the two workspace-level ability flags
|
||||
// (broadcast_enabled, talk_to_user_enabled). Both are mutated through the
|
||||
// same admin endpoint the ChatTab recovery banner already uses
|
||||
// (PATCH /workspaces/:id/abilities) and reflected into the canvas store node
|
||||
// data (broadcastEnabled / talkToUserEnabled) so every surface that reads
|
||||
// useCanvasStore.nodes stays consistent without a full re-hydrate.
|
||||
//
|
||||
// Before this section there was NO canvas control for either flag: the
|
||||
// backend was fully wired (workspace_abilities.go / workspace_broadcast.go /
|
||||
// agent_message_writer.go, see commit 29b4bffb + internal#510/#511) but the
|
||||
// only frontend affordance was the ChatTab recovery banner, which renders
|
||||
// solely when talk_to_user_enabled===false and so is invisible under the
|
||||
// TRUE default and never existed at all for broadcast.
|
||||
function AgentAbilitiesSection({ workspaceId }: { workspaceId: string }) {
|
||||
// Read the live ability flags off the canvas store node — the platform
|
||||
// event stream hydrates these (canvas-topology.ts maps the workspace row's
|
||||
// broadcast_enabled/talk_to_user_enabled onto node data), so this stays in
|
||||
// sync with the recovery banner and avoids a duplicate GET. Mirrors the
|
||||
// store-read pattern used by AgentCardSection above.
|
||||
const node = useCanvasStore((s) =>
|
||||
s.nodes?.find?.((n) => n.id === workspaceId),
|
||||
);
|
||||
// Defaults match the backend column defaults + canvas-topology mapping:
|
||||
// broadcast_enabled defaults FALSE, talk_to_user_enabled defaults TRUE.
|
||||
const broadcastEnabled = node?.data.broadcastEnabled ?? false;
|
||||
const talkToUserEnabled = node?.data.talkToUserEnabled ?? true;
|
||||
|
||||
// Track an in-flight PATCH per field so a double-click can't fire two
|
||||
// racing writes, and surface a one-line error if the server rejects.
|
||||
const [pending, setPending] = useState<null | "broadcast" | "talk">(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const patchAbility = async (
|
||||
which: "broadcast" | "talk",
|
||||
body: { broadcast_enabled: boolean } | { talk_to_user_enabled: boolean },
|
||||
optimistic: Partial<{ broadcastEnabled: boolean; talkToUserEnabled: boolean }>,
|
||||
) => {
|
||||
setError(null);
|
||||
setPending(which);
|
||||
// Optimistic store update — the toggle flips immediately; on failure we
|
||||
// roll back to the server-truth value the store last held.
|
||||
const prev = {
|
||||
broadcastEnabled,
|
||||
talkToUserEnabled,
|
||||
};
|
||||
useCanvasStore.getState().updateNodeData(workspaceId, optimistic);
|
||||
try {
|
||||
await api.patch(`/workspaces/${workspaceId}/abilities`, body);
|
||||
} catch (e) {
|
||||
// Roll back the optimistic change to last-known server truth.
|
||||
useCanvasStore.getState().updateNodeData(workspaceId, {
|
||||
broadcastEnabled: prev.broadcastEnabled,
|
||||
talkToUserEnabled: prev.talkToUserEnabled,
|
||||
});
|
||||
setError(
|
||||
e instanceof Error ? e.message : "Failed to update ability — try again",
|
||||
);
|
||||
} finally {
|
||||
setPending(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Section title="Agent Abilities">
|
||||
<p className="text-[10px] text-ink-mid px-1 pb-1">
|
||||
Workspace-level permissions for this agent. Changes apply immediately
|
||||
(no restart required).
|
||||
</p>
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<Toggle
|
||||
label="Talk to user"
|
||||
checked={talkToUserEnabled}
|
||||
onChange={(v) =>
|
||||
pending
|
||||
? undefined
|
||||
: patchAbility(
|
||||
"talk",
|
||||
{ talk_to_user_enabled: v },
|
||||
{ talkToUserEnabled: v },
|
||||
)
|
||||
}
|
||||
/>
|
||||
<p className="text-[10px] text-ink-mid mt-0.5 ml-6">
|
||||
When off, the agent's <code className="font-mono">send_message_to_user</code>{" "}
|
||||
and <code className="font-mono">POST /notify</code> calls are
|
||||
rejected (403) — it must route updates through a parent workspace.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Toggle
|
||||
label="Broadcast to peers"
|
||||
checked={broadcastEnabled}
|
||||
onChange={(v) =>
|
||||
pending
|
||||
? undefined
|
||||
: patchAbility(
|
||||
"broadcast",
|
||||
{ broadcast_enabled: v },
|
||||
{ broadcastEnabled: v },
|
||||
)
|
||||
}
|
||||
/>
|
||||
<p className="text-[10px] text-ink-mid mt-0.5 ml-6">
|
||||
When on, the agent may <code className="font-mono">POST /broadcast</code>{" "}
|
||||
to message all non-removed agent workspaces in the org. Off by
|
||||
default — only privileged orchestrators should hold this.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{pending && (
|
||||
<div className="mt-2 text-[10px] text-ink-mid">Saving…</div>
|
||||
)}
|
||||
{error && (
|
||||
<div role="alert" aria-live="assertive" className="mt-2 px-2 py-1 bg-red-900/30 border border-red-800 rounded text-[10px] text-bad">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Main ConfigTab ---
|
||||
|
||||
interface ModelSpec {
|
||||
@@ -919,7 +795,6 @@ export function ConfigTab({ workspaceId }: Props) {
|
||||
<label className="text-[10px] text-ink-mid block mb-1">Model</label>
|
||||
<input
|
||||
type="text"
|
||||
aria-label="Model"
|
||||
value={currentModelId}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
@@ -1010,8 +885,6 @@ export function ConfigTab({ workspaceId }: Props) {
|
||||
)}
|
||||
</Section>
|
||||
|
||||
<AgentAbilitiesSection workspaceId={workspaceId} />
|
||||
|
||||
{/* Claude Settings — shown for claude-code runtime or claude/anthropic model names */}
|
||||
{(config.runtime === "claude-code" ||
|
||||
(config.runtime_config?.model || config.model || "").toLowerCase().includes("claude") ||
|
||||
@@ -1122,7 +995,7 @@ export function ConfigTab({ workspaceId }: Props) {
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div role="alert" aria-live="assertive" className="mx-3 mb-2 px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-bad">{error}</div>
|
||||
<div className="mx-3 mb-2 px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-bad">{error}</div>
|
||||
)}
|
||||
{!error && RUNTIMES_WITH_OWN_CONFIG.has(config.runtime || "") && (
|
||||
<div className="mx-3 mb-2 px-3 py-1.5 bg-surface-sunken/50 border border-line rounded text-xs text-ink-mid">
|
||||
|
||||
@@ -157,7 +157,7 @@ export function DetailsTab({ workspaceId, data }: Props) {
|
||||
</select>
|
||||
</Field>
|
||||
{saveError && (
|
||||
<div role="alert" aria-live="assertive" className="px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-bad">
|
||||
<div className="px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-bad">
|
||||
{saveError}
|
||||
</div>
|
||||
)}
|
||||
@@ -203,7 +203,7 @@ export function DetailsTab({ workspaceId, data }: Props) {
|
||||
{isRestartable && (
|
||||
<div className="pt-2">
|
||||
{restartError && (
|
||||
<div role="alert" aria-live="assertive" className="mb-2 px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-bad">
|
||||
<div className="mb-2 px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-bad">
|
||||
{restartError}
|
||||
</div>
|
||||
)}
|
||||
@@ -307,7 +307,7 @@ export function DetailsTab({ workspaceId, data }: Props) {
|
||||
{/* Delete */}
|
||||
<Section title="Danger Zone">
|
||||
{deleteError && (
|
||||
<div role="alert" aria-live="assertive" className="mb-2 px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-bad">
|
||||
<div className="mb-2 px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-bad">
|
||||
{deleteError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -82,7 +82,7 @@ export function EventsTab({ workspaceId }: Props) {
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div role="alert" aria-live="assertive" className="px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-bad">
|
||||
<div className="px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-bad">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -102,7 +102,7 @@ export function ExternalConnectionSection({ workspaceId }: Props) {
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div role="alert" aria-live="assertive" className="mt-2 px-2 py-1 bg-red-900/30 border border-red-800 rounded text-[10px] text-bad">
|
||||
<div className="mt-2 px-2 py-1 bg-red-900/30 border border-red-800 rounded text-[10px] text-bad">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -266,7 +266,7 @@ function PlatformOwnedFilesTab({
|
||||
// immediately. Delete-All hovers DARKER (bg-red-700) — same AA
|
||||
// contrast trap that bit ConfirmDialog/ApprovalBanner. Cancel
|
||||
// lifts to surface-elevated instead of the prior no-op hover.
|
||||
<div role="alertdialog" aria-modal="false" aria-labelledby="files-delete-all-msg" className="mx-3 mt-2 px-3 py-2 bg-red-950/30 border border-red-800/40 rounded space-y-1.5">
|
||||
<div role="alertdialog" aria-labelledby="files-delete-all-msg" className="mx-3 mt-2 px-3 py-2 bg-red-950/30 border border-red-800/40 rounded space-y-1.5">
|
||||
<p id="files-delete-all-msg" className="text-xs text-bad">Delete all {files.filter((f) => !f.dir).length} files? This cannot be undone.</p>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={() => { handleDeleteAll(); setShowDeleteAll(false); }} className="px-2 py-0.5 bg-red-700 hover:bg-red-600 text-[10px] rounded text-white transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60 focus-visible:ring-offset-1 focus-visible:ring-offset-surface">Delete All</button>
|
||||
@@ -280,7 +280,7 @@ function PlatformOwnedFilesTab({
|
||||
)}
|
||||
|
||||
{confirmDelete && (
|
||||
<div role="alertdialog" aria-modal="false" aria-labelledby="files-delete-one-msg" className="mx-3 mt-2 px-3 py-2 bg-amber-950/30 border border-amber-800/40 rounded space-y-1.5">
|
||||
<div role="alertdialog" aria-labelledby="files-delete-one-msg" className="mx-3 mt-2 px-3 py-2 bg-amber-950/30 border border-amber-800/40 rounded space-y-1.5">
|
||||
<p id="files-delete-one-msg" className="text-xs text-warm">Delete <span className="font-mono">{confirmDelete}</span>{files.find((f) => f.path === confirmDelete && f.dir) ? " and all its contents" : ""}?</p>
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={confirmDeleteFile} className="px-2 py-0.5 bg-red-700 hover:bg-red-600 text-[10px] rounded text-white transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60 focus-visible:ring-offset-1 focus-visible:ring-offset-surface">Delete</button>
|
||||
|
||||
@@ -88,7 +88,7 @@ export function FileEditor({
|
||||
<button
|
||||
onClick={onDownload}
|
||||
aria-label="Download file"
|
||||
className="text-[10px] text-ink-mid hover:text-ink-mid"
|
||||
className="text-[10px] text-ink-mid hover:text-ink-mid focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 rounded transition-colors"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
@@ -96,7 +96,7 @@ export function FileEditor({
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={!isDirty || saving}
|
||||
className="text-[10px] text-accent hover:text-accent disabled:opacity-30"
|
||||
className="text-[10px] text-accent hover:text-accent disabled:opacity-30 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 rounded transition-colors"
|
||||
>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* Tests for FileTree — complements FileTreeContextMenu.test.tsx with:
|
||||
* - Empty tree render
|
||||
* - File row: icon, name, selection highlight
|
||||
* - Directory row: folder icon, expand/collapse chevron, loading indicator
|
||||
* - Directory expand/collapse via click
|
||||
* - File select callback
|
||||
* - Delete button: aria-label, stopPropagation
|
||||
* - Drop-target highlight (drag hover)
|
||||
* - Context menu opens on right-click
|
||||
* - Nested tree: recursive rendering
|
||||
* - WCAG: aria-label on all interactive elements
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, createEvent, cleanup } from "@testing-library/react";
|
||||
|
||||
// ── Mock FileTreeContextMenu (rendered by FileTree on right-click) ─────────────
|
||||
vi.mock("../FileTreeContextMenu", () => ({
|
||||
FileTreeContextMenu: ({ items }: { items: Array<{ id: string; label: string; disabled?: boolean }>; onClose: () => void }) => (
|
||||
<div data-testid="file-context-menu">
|
||||
{items.map((item, i) => (
|
||||
<button key={item.id} data-menu-id={item.id} role="menuitem" disabled={item.disabled}>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// ── Import component + types AFTER mocks ────────────────────────────────────────
|
||||
import { FileTree } from "../FileTree";
|
||||
import type { TreeNode } from "../tree";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// ── Test helpers ───────────────────────────────────────────────────────────────
|
||||
const makeNode = (
|
||||
name: string,
|
||||
opts: Partial<{
|
||||
isDir: boolean;
|
||||
path: string;
|
||||
children: TreeNode[];
|
||||
}>
|
||||
): TreeNode => ({
|
||||
name,
|
||||
path: opts.path ?? `/${name}`,
|
||||
isDir: opts.isDir ?? false,
|
||||
children: opts.children ?? [],
|
||||
size: 0,
|
||||
});
|
||||
|
||||
const EMPTY_CALLBACKS = {
|
||||
selectedPath: null as string | null,
|
||||
onSelect: vi.fn(),
|
||||
onDelete: vi.fn(),
|
||||
onDownload: vi.fn(),
|
||||
canDelete: true,
|
||||
expandedDirs: new Set<string>(),
|
||||
onToggleDir: vi.fn(),
|
||||
loadingDir: null as string | null,
|
||||
};
|
||||
|
||||
describe("FileTree — empty render", () => {
|
||||
it("renders nothing when nodes is an empty array", () => {
|
||||
render(<FileTree nodes={[]} {...EMPTY_CALLBACKS} />);
|
||||
expect(document.body.textContent).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("FileTree — file row", () => {
|
||||
it("renders a file row with the file name", () => {
|
||||
const file = makeNode("config.yaml", { isDir: false });
|
||||
render(<FileTree nodes={[file]} {...EMPTY_CALLBACKS} />);
|
||||
expect(screen.getByText("config.yaml")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders file icon via getIcon (📜 for .yaml)", () => {
|
||||
const file = makeNode("README.md", { isDir: false });
|
||||
render(<FileTree nodes={[file]} {...EMPTY_CALLBACKS} />);
|
||||
// Icon is a span with the emoji
|
||||
const icon = document.querySelector('[class*="gap-1"] span');
|
||||
expect(icon?.textContent).toBeTruthy();
|
||||
});
|
||||
|
||||
it("file row has aria-label on the delete button", () => {
|
||||
const file = makeNode("script.py", { isDir: false });
|
||||
render(<FileTree nodes={[file]} {...EMPTY_CALLBACKS} />);
|
||||
const delBtn = document.querySelector('button[aria-label="Delete script.py"]');
|
||||
expect(delBtn).toBeTruthy();
|
||||
});
|
||||
|
||||
it("clicking a file row calls onSelect with the file path", () => {
|
||||
const onSelect = vi.fn();
|
||||
const file = makeNode("app.ts", { path: "/src/app.ts", isDir: false });
|
||||
render(<FileTree nodes={[file]} {...EMPTY_CALLBACKS} selectedPath={null} onSelect={onSelect} />);
|
||||
fireEvent.click(screen.getByText("app.ts"));
|
||||
expect(onSelect).toHaveBeenCalledWith("/src/app.ts");
|
||||
});
|
||||
|
||||
it("selected file has different background class than unselected", () => {
|
||||
const file = makeNode("main.py", { isDir: false });
|
||||
render(<FileTree nodes={[file]} {...EMPTY_CALLBACKS} selectedPath="/main.py" />);
|
||||
const row = document.querySelector('[class*="cursor-pointer"]') as HTMLElement;
|
||||
expect(row).toBeTruthy();
|
||||
// bg-blue-900/30 is applied when selected
|
||||
expect(row.className).toContain("bg-blue-900/30");
|
||||
});
|
||||
|
||||
it("clicking the delete button calls onDelete (stops propagation)", () => {
|
||||
const onSelect = vi.fn();
|
||||
const onDelete = vi.fn();
|
||||
const file = makeNode("temp.txt", { isDir: false });
|
||||
render(<FileTree nodes={[file]} {...EMPTY_CALLBACKS} onSelect={onSelect} onDelete={onDelete} />);
|
||||
const delBtn = screen.getByRole("button", { name: /Delete temp\.txt/i });
|
||||
fireEvent.click(delBtn);
|
||||
expect(onDelete).toHaveBeenCalledWith("/temp.txt");
|
||||
// onSelect should NOT be called (stopPropagation)
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FileTree — directory row", () => {
|
||||
it("renders a directory row with 📁 icon and directory name", () => {
|
||||
const dir = makeNode("src", { isDir: true, path: "/src" });
|
||||
render(<FileTree nodes={[dir]} {...EMPTY_CALLBACKS} />);
|
||||
expect(screen.getByText("src")).toBeTruthy();
|
||||
expect(screen.getByText("📁")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("directory shows ▶ chevron when collapsed", () => {
|
||||
const dir = makeNode("lib", { isDir: true, path: "/lib" });
|
||||
render(<FileTree nodes={[dir]} {...EMPTY_CALLBACKS} expandedDirs={new Set()} />);
|
||||
// collapsed → ▶
|
||||
expect(screen.getByText("▶")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("directory shows ▼ chevron when expanded", () => {
|
||||
const dir = makeNode("lib", { isDir: true, path: "/lib" });
|
||||
render(<FileTree nodes={[dir]} {...EMPTY_CALLBACKS} expandedDirs={new Set(["/lib"])} />);
|
||||
expect(screen.getByText("▼")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("directory shows … (loading indicator) when loadingDir matches", () => {
|
||||
const dir = makeNode("pkg", { isDir: true, path: "/pkg" });
|
||||
render(<FileTree nodes={[dir]} {...EMPTY_CALLBACKS} loadingDir="/pkg" expandedDirs={new Set(["/pkg"])} />);
|
||||
expect(screen.getByText("…")).toBeTruthy();
|
||||
// Chevron is replaced by loading indicator
|
||||
expect(screen.queryByText("▼")).toBeNull();
|
||||
});
|
||||
|
||||
it("clicking a collapsed directory calls onToggleDir", () => {
|
||||
const onToggleDir = vi.fn();
|
||||
const dir = makeNode("docs", { isDir: true, path: "/docs" });
|
||||
render(<FileTree nodes={[dir]} {...EMPTY_CALLBACKS} expandedDirs={new Set()} onToggleDir={onToggleDir} />);
|
||||
fireEvent.click(screen.getByText("docs"));
|
||||
expect(onToggleDir).toHaveBeenCalledWith("/docs");
|
||||
});
|
||||
|
||||
it("clicking an expanded directory calls onToggleDir to collapse", () => {
|
||||
const onToggleDir = vi.fn();
|
||||
const dir = makeNode("docs", { isDir: true, path: "/docs" });
|
||||
render(<FileTree nodes={[dir]} {...EMPTY_CALLBACKS} expandedDirs={new Set(["/docs"])} onToggleDir={onToggleDir} />);
|
||||
fireEvent.click(screen.getByText("docs"));
|
||||
expect(onToggleDir).toHaveBeenCalledWith("/docs");
|
||||
});
|
||||
|
||||
it("expanded directory renders its children recursively", () => {
|
||||
const childFile = makeNode("index.ts", { isDir: false, path: "/src/index.ts" });
|
||||
const dir = makeNode("src", { isDir: true, path: "/src", children: [childFile] });
|
||||
render(<FileTree nodes={[dir]} {...EMPTY_CALLBACKS} expandedDirs={new Set(["/src"])} />);
|
||||
expect(screen.getByText("index.ts")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("collapsed directory does NOT render its children", () => {
|
||||
const childFile = makeNode("inner.ts", { isDir: false, path: "/outer/inner.ts" });
|
||||
const dir = makeNode("outer", { isDir: true, path: "/outer", children: [childFile] });
|
||||
render(<FileTree nodes={[dir]} {...EMPTY_CALLBACKS} expandedDirs={new Set()} />);
|
||||
expect(screen.queryByText("inner.ts")).toBeNull();
|
||||
});
|
||||
|
||||
it("directory delete button calls onDelete", () => {
|
||||
const onDelete = vi.fn();
|
||||
const dir = makeNode("cache", { isDir: true, path: "/cache" });
|
||||
render(<FileTree nodes={[dir]} {...EMPTY_CALLBACKS} onDelete={onDelete} />);
|
||||
const delBtn = screen.getByRole("button", { name: /Delete cache/i });
|
||||
fireEvent.click(delBtn);
|
||||
expect(onDelete).toHaveBeenCalledWith("/cache");
|
||||
});
|
||||
|
||||
it("directory delete button in context menu is disabled when canDelete=false", () => {
|
||||
const dir = makeNode("locked", { isDir: true, path: "/locked" });
|
||||
render(<FileTree nodes={[dir]} {...EMPTY_CALLBACKS} canDelete={false} />);
|
||||
// Right-click to open context menu
|
||||
const row = document.querySelector('[class*="cursor-pointer"]') as HTMLElement;
|
||||
fireEvent.contextMenu(row);
|
||||
// Query inside the context menu — use role=menuitem (real component uses this)
|
||||
// and verify the disabled attribute (vitest-compatible, no jest-dom needed)
|
||||
const ctxMenu = screen.getByTestId("file-context-menu");
|
||||
const delBtn = ctxMenu.querySelector('button[role="menuitem"]') as HTMLButtonElement | null;
|
||||
expect(delBtn).not.toBeNull();
|
||||
expect(delBtn!.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("FileTree — context menu", () => {
|
||||
it("right-clicking a file opens the context menu", () => {
|
||||
const file = makeNode("data.json", { isDir: false });
|
||||
render(<FileTree nodes={[file]} {...EMPTY_CALLBACKS} />);
|
||||
const row = document.querySelector('[class*="cursor-pointer"]') as HTMLElement;
|
||||
fireEvent.contextMenu(row);
|
||||
expect(screen.getByTestId("file-context-menu")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("context menu shows 'Open' and 'Download' for a file", () => {
|
||||
const file = makeNode("report.csv", { isDir: false });
|
||||
render(<FileTree nodes={[file]} {...EMPTY_CALLBACKS} />);
|
||||
const row = document.querySelector('[class*="cursor-pointer"]') as HTMLElement;
|
||||
fireEvent.contextMenu(row);
|
||||
expect(screen.getByText("Open")).toBeTruthy();
|
||||
expect(screen.getByText("Download")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("context menu shows only 'Delete' for a directory (no Open/Download)", () => {
|
||||
const dir = makeNode("logs", { isDir: true, path: "/logs" });
|
||||
render(<FileTree nodes={[dir]} {...EMPTY_CALLBACKS} />);
|
||||
const row = document.querySelector('[class*="cursor-pointer"]') as HTMLElement;
|
||||
fireEvent.contextMenu(row);
|
||||
expect(screen.getByText("Delete")).toBeTruthy();
|
||||
expect(screen.queryByText("Open")).toBeNull();
|
||||
expect(screen.queryByText("Download")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FileTree — drag-drop target highlight (PR-D)", () => {
|
||||
it("directory row handles dragOver without crashing", () => {
|
||||
const onDropToTarget = vi.fn();
|
||||
const dir = makeNode("dropdir", { isDir: true, path: "/dropdir" });
|
||||
render(<FileTree nodes={[dir]} {...EMPTY_CALLBACKS} onDropToTarget={onDropToTarget} expandedDirs={new Set()} />);
|
||||
const row = document.querySelector('[class*="cursor-pointer"]') as HTMLElement;
|
||||
expect(row).toBeTruthy();
|
||||
// jsdom's DragEvent is not available; use RTL's createEvent + dispatchEvent
|
||||
// and stub dataTransfer so the handler's e.dataTransfer.dropEffect = "copy"
|
||||
// assignment inside FileTree doesn't throw.
|
||||
const dragOverEvent = createEvent.dragOver(row);
|
||||
Object.defineProperty(dragOverEvent, "dataTransfer", {
|
||||
value: { dropEffect: "none" },
|
||||
});
|
||||
row.dispatchEvent(dragOverEvent);
|
||||
// Component should still show the node without crashing.
|
||||
expect(screen.queryByText("dropdir")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("non-directory rows do not crash when onDropToTarget is provided", () => {
|
||||
const onDropToTarget = vi.fn();
|
||||
const file = makeNode("data.csv", { isDir: false, path: "/data.csv" });
|
||||
// Should render without error even with onDropToTarget (files ignore it)
|
||||
render(<FileTree nodes={[file]} {...EMPTY_CALLBACKS} onDropToTarget={onDropToTarget} expandedDirs={new Set()} />);
|
||||
expect(screen.getByText("data.csv")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("FileTree — nested tree", () => {
|
||||
it("three-level deep tree renders all three levels", () => {
|
||||
const level3 = makeNode("deep.ts", { isDir: false, path: "/a/b/c/deep.ts" });
|
||||
const level2 = makeNode("b", { isDir: true, path: "/a/b", children: [level3] });
|
||||
const level1 = makeNode("a", { isDir: true, path: "/a", children: [level2] });
|
||||
render(<FileTree nodes={[level1]} {...EMPTY_CALLBACKS} expandedDirs={new Set(["/a", "/a/b"])} />);
|
||||
expect(screen.getByText("a")).toBeTruthy();
|
||||
expect(screen.getByText("b")).toBeTruthy();
|
||||
expect(screen.getByText("deep.ts")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("only renders expanded paths — /a expanded but /a/b collapsed hides level 3", () => {
|
||||
const level3 = makeNode("secret.ts", { isDir: false, path: "/a/b/secret.ts" });
|
||||
const level2 = makeNode("b", { isDir: true, path: "/a/b", children: [level3] });
|
||||
const level1 = makeNode("a", { isDir: true, path: "/a", children: [level2] });
|
||||
render(<FileTree nodes={[level1]} {...EMPTY_CALLBACKS} expandedDirs={new Set(["/a"])} />);
|
||||
// "a" is expanded: shows name + "b" as a collapsed child
|
||||
expect(screen.getByText("a")).toBeTruthy();
|
||||
expect(screen.getByText("▶")).toBeTruthy(); // "b" is collapsed (▶ not ▼)
|
||||
// "secret.ts" is NOT rendered because /a/b is not expanded
|
||||
expect(screen.queryByText("secret.ts")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -275,7 +275,7 @@ export function ScheduleTab({ workspaceId }: Props) {
|
||||
Enabled
|
||||
</label>
|
||||
</div>
|
||||
{error && <div role="alert" aria-live="assertive" className="text-[10px] text-bad">{error}</div>}
|
||||
{error && <div className="text-[10px] text-bad">{error}</div>}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -325,7 +325,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowRegistry(true)}
|
||||
className="rounded-full border border-violet-700/50 bg-violet-950/30 px-3 py-0.5 text-[10px] text-violet-200 hover:bg-violet-900/40 transition-colors"
|
||||
className="rounded-full border border-violet-700/50 bg-violet-950/30 px-3 py-0.5 text-[10px] text-violet-200 hover:bg-violet-900/40 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-violet-400"
|
||||
aria-expanded="false"
|
||||
aria-controls="plugins-section"
|
||||
>
|
||||
@@ -349,7 +349,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowRegistry(!showRegistry)}
|
||||
className="rounded-full border border-violet-700/50 bg-violet-950/30 px-3 py-1 text-[10px] text-violet-200 hover:bg-violet-900/40 transition-colors"
|
||||
className="rounded-full border border-violet-700/50 bg-violet-950/30 px-3 py-1 text-[10px] text-violet-200 hover:bg-violet-900/40 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-violet-400"
|
||||
aria-expanded={showRegistry}
|
||||
aria-controls="plugins-registry"
|
||||
>
|
||||
@@ -401,7 +401,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
|
||||
<button
|
||||
onClick={() => handleUninstall(p.name)}
|
||||
disabled={uninstalling === p.name}
|
||||
className="shrink-0 rounded-full border border-red-800/40 bg-red-950/20 px-2 py-0.5 text-[11px] text-bad hover:bg-red-900/30 disabled:opacity-30"
|
||||
className="shrink-0 rounded-full border border-red-800/40 bg-red-950/20 px-2 py-0.5 text-[11px] text-bad hover:bg-red-900/30 disabled:opacity-30 focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400"
|
||||
>
|
||||
{uninstalling === p.name ? "..." : "Remove"}
|
||||
</button>
|
||||
@@ -449,7 +449,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
|
||||
<button
|
||||
onClick={handleInstallCustom}
|
||||
disabled={!customSource.trim() || installing !== null}
|
||||
className="shrink-0 rounded-full border border-violet-700/50 bg-violet-950/30 px-2.5 py-1 text-[11px] text-violet-300 hover:bg-violet-900/40 disabled:opacity-30"
|
||||
className="shrink-0 rounded-full border border-violet-700/50 bg-violet-950/30 px-2.5 py-1 text-[11px] text-violet-300 hover:bg-violet-900/40 disabled:opacity-30 focus:outline-none focus-visible:ring-2 focus-visible:ring-violet-400"
|
||||
>
|
||||
{installing === customSource.trim() ? "Installing..." : "Install"}
|
||||
</button>
|
||||
@@ -538,7 +538,7 @@ export function SkillsTab({ workspaceId, data }: Props) {
|
||||
<button
|
||||
onClick={() => handleInstall(p.name)}
|
||||
disabled={installing === p.name}
|
||||
className="shrink-0 rounded-full border border-violet-700/50 bg-violet-950/30 px-2.5 py-0.5 text-[11px] text-violet-300 hover:bg-violet-900/40 disabled:opacity-30"
|
||||
className="shrink-0 rounded-full border border-violet-700/50 bg-violet-950/30 px-2.5 py-0.5 text-[11px] text-violet-300 hover:bg-violet-900/40 disabled:opacity-30 focus:outline-none focus-visible:ring-2 focus-visible:ring-violet-400"
|
||||
>
|
||||
{installing === p.name ? "Installing..." : "Install"}
|
||||
</button>
|
||||
@@ -570,13 +570,13 @@ export function SkillsTab({ workspaceId, data }: Props) {
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => setPanelTab("config")}
|
||||
className="rounded-full border border-line bg-surface px-3 py-1 text-[10px] text-ink-mid hover:bg-surface-sunken"
|
||||
className="rounded-full border border-line bg-surface px-3 py-1 text-[10px] text-ink-mid hover:bg-surface-sunken focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
Open Config
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPanelTab("files")}
|
||||
className="rounded-full border border-line bg-surface px-3 py-1 text-[10px] text-ink-mid hover:bg-surface-sunken"
|
||||
className="rounded-full border border-line bg-surface px-3 py-1 text-[10px] text-ink-mid hover:bg-surface-sunken focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
Open Files
|
||||
</button>
|
||||
|
||||
@@ -67,7 +67,7 @@ export function TracesTab({ workspaceId }: Props) {
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div role="alert" aria-live="assertive" className="px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-bad">
|
||||
<div className="px-3 py-1.5 bg-red-900/30 border border-red-800 rounded text-xs text-bad">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Tests for the always-visible "Agent Abilities" section added to ConfigTab
|
||||
// (internal#510 broadcast_enabled, internal#511 talk_to_user_enabled; backend
|
||||
// wired in commit 29b4bffb).
|
||||
//
|
||||
// Problem this pins: the two workspace ability flags had complete wired
|
||||
// backends but NO canvas control — broadcast had none at all, talk-to-user
|
||||
// only surfaced as a ChatTab recovery banner that is invisible under its
|
||||
// TRUE default. The CTO could not see or toggle either from canvas.
|
||||
//
|
||||
// What this suite pins:
|
||||
// 1. An "Agent Abilities" section renders (always visible, not gated).
|
||||
// 2. Both toggles render and reflect the store node's ability fields,
|
||||
// including the asymmetric defaults (broadcast FALSE, talk TRUE).
|
||||
// 3. Toggling a switch calls PATCH /workspaces/:id/abilities with the
|
||||
// correct snake_case body and optimistically updates the store.
|
||||
|
||||
import { describe, it, expect, vi, afterEach, beforeEach } from "vitest";
|
||||
import { render, screen, cleanup, waitFor, fireEvent } from "@testing-library/react";
|
||||
import React from "react";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const apiGet = vi.fn();
|
||||
const apiPatch = vi.fn();
|
||||
vi.mock("@/lib/api", () => ({
|
||||
api: {
|
||||
get: (path: string) => apiGet(path),
|
||||
patch: (path: string, body?: unknown) => apiPatch(path, body),
|
||||
put: vi.fn(),
|
||||
post: vi.fn(),
|
||||
del: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Store node carries the ability flags hydrated by the platform stream
|
||||
// (canvas-topology.ts maps broadcast_enabled/talk_to_user_enabled onto
|
||||
// node.data). Mirror that shape so the section reads real values.
|
||||
const storeUpdateNodeData = vi.fn();
|
||||
const storeRestartWorkspace = vi.fn();
|
||||
let nodeData: { broadcastEnabled?: boolean; talkToUserEnabled?: boolean } = {};
|
||||
const makeState = () => ({
|
||||
nodes: [{ id: "ws-test", data: nodeData }],
|
||||
restartWorkspace: storeRestartWorkspace,
|
||||
updateNodeData: storeUpdateNodeData,
|
||||
});
|
||||
vi.mock("@/store/canvas", () => ({
|
||||
useCanvasStore: Object.assign(
|
||||
(selector: (s: unknown) => unknown) => selector(makeState()),
|
||||
{ getState: () => makeState() },
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../AgentCardSection", () => ({
|
||||
AgentCardSection: () => <div data-testid="agent-card-stub" />,
|
||||
}));
|
||||
|
||||
import { ConfigTab } from "../ConfigTab";
|
||||
|
||||
beforeEach(() => {
|
||||
apiGet.mockReset();
|
||||
apiPatch.mockReset();
|
||||
apiPatch.mockResolvedValue({ status: "updated" });
|
||||
storeUpdateNodeData.mockReset();
|
||||
apiGet.mockImplementation((path: string) => {
|
||||
if (path === `/workspaces/ws-test`) {
|
||||
return Promise.resolve({ runtime: "claude-code" });
|
||||
}
|
||||
if (path === `/workspaces/ws-test/model`) {
|
||||
return Promise.resolve({ model: "claude-opus-4-7" });
|
||||
}
|
||||
if (path === `/workspaces/ws-test/provider`) {
|
||||
return Promise.resolve({ provider: "anthropic-oauth", source: "default" });
|
||||
}
|
||||
if (path === `/workspaces/ws-test/files/config.yaml`) {
|
||||
return Promise.resolve({ content: "name: test\nruntime: claude-code\n" });
|
||||
}
|
||||
if (path === "/templates") {
|
||||
return Promise.resolve([
|
||||
{ id: "claude-code", name: "Claude Code", runtime: "claude-code", providers: [] },
|
||||
]);
|
||||
}
|
||||
return Promise.reject(new Error(`unmocked api.get: ${path}`));
|
||||
});
|
||||
});
|
||||
|
||||
describe("ConfigTab Agent Abilities section", () => {
|
||||
it("renders an always-visible 'Agent Abilities' section with both toggles", async () => {
|
||||
nodeData = {}; // unset → defaults
|
||||
render(<ConfigTab workspaceId="ws-test" />);
|
||||
await waitFor(() => expect(apiGet).toHaveBeenCalled());
|
||||
expect(
|
||||
await screen.findByRole("button", { name: /Agent Abilities/i }),
|
||||
).toBeTruthy();
|
||||
expect(screen.getByText("Talk to user")).toBeTruthy();
|
||||
expect(screen.getByText("Broadcast to peers")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("reflects the asymmetric defaults: talk-to-user ON, broadcast OFF", async () => {
|
||||
nodeData = {}; // unset → backend defaults
|
||||
render(<ConfigTab workspaceId="ws-test" />);
|
||||
await waitFor(() => expect(apiGet).toHaveBeenCalled());
|
||||
const talk = (await screen.findByText("Talk to user"))
|
||||
.closest("label")!
|
||||
.querySelector("input") as HTMLInputElement;
|
||||
const broadcast = screen
|
||||
.getByText("Broadcast to peers")
|
||||
.closest("label")!
|
||||
.querySelector("input") as HTMLInputElement;
|
||||
expect(talk.checked).toBe(true);
|
||||
expect(broadcast.checked).toBe(false);
|
||||
});
|
||||
|
||||
it("reflects explicit store values", async () => {
|
||||
nodeData = { broadcastEnabled: true, talkToUserEnabled: false };
|
||||
render(<ConfigTab workspaceId="ws-test" />);
|
||||
await waitFor(() => expect(apiGet).toHaveBeenCalled());
|
||||
const talk = (await screen.findByText("Talk to user"))
|
||||
.closest("label")!
|
||||
.querySelector("input") as HTMLInputElement;
|
||||
const broadcast = screen
|
||||
.getByText("Broadcast to peers")
|
||||
.closest("label")!
|
||||
.querySelector("input") as HTMLInputElement;
|
||||
expect(talk.checked).toBe(false);
|
||||
expect(broadcast.checked).toBe(true);
|
||||
});
|
||||
|
||||
it("PATCHes /abilities with talk_to_user_enabled and optimistically updates the store", async () => {
|
||||
nodeData = {}; // talk defaults true
|
||||
render(<ConfigTab workspaceId="ws-test" />);
|
||||
await waitFor(() => expect(apiGet).toHaveBeenCalled());
|
||||
const talk = (await screen.findByText("Talk to user"))
|
||||
.closest("label")!
|
||||
.querySelector("input") as HTMLInputElement;
|
||||
fireEvent.click(talk); // true → false
|
||||
await waitFor(() =>
|
||||
expect(apiPatch).toHaveBeenCalledWith("/workspaces/ws-test/abilities", {
|
||||
talk_to_user_enabled: false,
|
||||
}),
|
||||
);
|
||||
expect(storeUpdateNodeData).toHaveBeenCalledWith("ws-test", {
|
||||
talkToUserEnabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("PATCHes /abilities with broadcast_enabled when the broadcast toggle is flipped", async () => {
|
||||
nodeData = {}; // broadcast defaults false
|
||||
render(<ConfigTab workspaceId="ws-test" />);
|
||||
await waitFor(() => expect(apiGet).toHaveBeenCalled());
|
||||
const broadcast = (await screen.findByText("Broadcast to peers"))
|
||||
.closest("label")!
|
||||
.querySelector("input") as HTMLInputElement;
|
||||
fireEvent.click(broadcast); // false → true
|
||||
await waitFor(() =>
|
||||
expect(apiPatch).toHaveBeenCalledWith("/workspaces/ws-test/abilities", {
|
||||
broadcast_enabled: true,
|
||||
}),
|
||||
);
|
||||
expect(storeUpdateNodeData).toHaveBeenCalledWith("ws-test", {
|
||||
broadcastEnabled: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -405,7 +405,7 @@ export function AgentCommsPanel({ workspaceId }: { workspaceId: string }) {
|
||||
</p>
|
||||
<button
|
||||
onClick={loadInitial}
|
||||
className="text-[10px] px-2 py-0.5 rounded bg-red-800/40 text-bad hover:bg-red-700/50 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500/60 focus-visible:ring-offset-1"
|
||||
className="text-[10px] px-2 py-0.5 rounded bg-red-800/40 text-bad hover:bg-red-700/50 transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-red-400"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
@@ -610,7 +610,7 @@ function PeerTabButton({
|
||||
aria-selected={active}
|
||||
tabIndex={active ? 0 : -1}
|
||||
onClick={onClick}
|
||||
className={`shrink-0 px-3 py-1.5 text-[10px] font-medium transition-colors whitespace-nowrap focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-cyan-500/60 focus-visible:ring-offset-1 ${
|
||||
className={`shrink-0 px-3 py-1.5 text-[10px] font-medium transition-colors whitespace-nowrap focus:outline-none focus-visible:ring-2 focus-visible:ring-cyan-400 ${
|
||||
active
|
||||
? "border-b-2 border-cyan-500 text-cyan-200"
|
||||
: "border-b-2 border-transparent text-ink-mid hover:text-ink-mid"
|
||||
|
||||
@@ -33,7 +33,7 @@ export function PendingAttachmentPill({
|
||||
<button
|
||||
onClick={onRemove}
|
||||
aria-label={`Remove ${file.name}`}
|
||||
className="ml-0.5 text-ink-mid hover:text-ink transition-colors shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-1"
|
||||
className="ml-0.5 text-ink-mid hover:text-ink transition-colors shrink-0 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40"
|
||||
>
|
||||
<svg width="10" height="10" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
||||
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||
@@ -62,9 +62,8 @@ export function AttachmentChip({
|
||||
return (
|
||||
<button
|
||||
onClick={() => onDownload(attachment)}
|
||||
aria-label={`Download ${attachment.name}`}
|
||||
title={`Download ${attachment.name}`}
|
||||
className={`flex items-center gap-1.5 rounded-md border px-2 py-1 text-[10px] transition-colors max-w-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 focus-visible:ring-offset-1 ${toneClasses}`}
|
||||
className={`flex items-center gap-1.5 rounded-md border px-2 py-1 text-[10px] transition-colors max-w-full focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 ${toneClasses}`}
|
||||
>
|
||||
<FileGlyph className="shrink-0 opacity-70" />
|
||||
<span className="truncate">{attachment.name}</span>
|
||||
|
||||
@@ -351,10 +351,8 @@ export function SecretsSection({ workspaceId, requiredEnv }: { workspaceId: stri
|
||||
{showAdd ? (
|
||||
<div className="bg-surface-card/50 rounded p-2 space-y-1.5 border border-line/50">
|
||||
<input value={newKey} onChange={(e) => setNewKey(e.target.value.toUpperCase())} placeholder="KEY_NAME"
|
||||
aria-label="Secret key name"
|
||||
className="w-full bg-surface-sunken border border-line rounded px-2 py-1 text-[10px] font-mono text-ink focus:outline-none focus:border-accent" />
|
||||
<input value={newValue} onChange={(e) => setNewValue(e.target.value)} placeholder="Value" type="password"
|
||||
aria-label="Secret value"
|
||||
className="w-full bg-surface-sunken border border-line rounded px-2 py-1 text-[10px] text-ink focus:outline-none focus:border-accent" />
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={() => { if (newKey && newValue) handleSave(newKey, newValue); }} disabled={!newKey || !newValue}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import type { TestConnectionState, SecretGroup } from '@/types/secrets';
|
||||
import { validateSecret, ApiError } from '@/lib/api/secrets';
|
||||
import { validateSecret } from '@/lib/api/secrets';
|
||||
|
||||
interface TestConnectionButtonProps {
|
||||
provider: SecretGroup;
|
||||
@@ -55,23 +55,9 @@ export function TestConnectionButton({
|
||||
}
|
||||
onResult?.(result.valid);
|
||||
resetTimerRef.current = setTimeout(() => setState('idle'), RESET_DELAYS[nextState]!);
|
||||
} catch (err) {
|
||||
// Distinguish a real failure shape rather than always claiming a
|
||||
// timeout. A reachable server that answered with an HTTP status
|
||||
// (ApiError) did NOT time out — most commonly the validation route
|
||||
// is not available (404/501), which must not masquerade as
|
||||
// "service down". Only an actual thrown network/abort error is a
|
||||
// connectivity failure.
|
||||
} catch {
|
||||
setState('failure');
|
||||
if (err instanceof ApiError) {
|
||||
setErrorDetail(
|
||||
err.status === 404 || err.status === 501
|
||||
? 'Key validation is not available for this service yet. The key was not tested.'
|
||||
: `Could not verify key (server returned ${err.status}). Saving is unaffected.`,
|
||||
);
|
||||
} else {
|
||||
setErrorDetail('Could not reach the validation service. Check your connection and try again.');
|
||||
}
|
||||
setErrorDetail('Connection timed out. Service may be down.');
|
||||
onResult?.(false);
|
||||
resetTimerRef.current = setTimeout(() => setState('idle'), RESET_DELAYS.failure);
|
||||
}
|
||||
@@ -99,7 +85,7 @@ export function TestConnectionButton({
|
||||
|
||||
function Spinner() {
|
||||
return (
|
||||
<svg aria-hidden="true" className="spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<svg className="spinner" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -28,20 +28,8 @@ const mockValidateSecret = vi.fn();
|
||||
|
||||
vi.mock("@/lib/api/secrets", () => ({
|
||||
validateSecret: (...args: unknown[]) => mockValidateSecret(...args),
|
||||
ApiError: class ApiError extends Error {
|
||||
status: number;
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
// Re-import the mocked ApiError so test cases construct the same class the
|
||||
// component's `instanceof` check sees.
|
||||
import { ApiError } from "@/lib/api/secrets";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.clearAllMocks();
|
||||
@@ -213,27 +201,8 @@ describe("TestConnectionButton — failure path", () => {
|
||||
});
|
||||
|
||||
describe("TestConnectionButton — catch path", () => {
|
||||
it("does NOT claim a timeout when the validate endpoint 404s (regression: internal#492)", async () => {
|
||||
// The validate route is unimplemented on the server and returns a fast
|
||||
// 404. Before the fix this rendered the misleading hardcoded string
|
||||
// "Connection timed out. Service may be down." It must instead state
|
||||
// honestly that validation isn't available and the key was not tested.
|
||||
mockValidateSecret.mockRejectedValue(new ApiError(404, "Not Found"));
|
||||
render(
|
||||
<TestConnectionButton provider="anthropic" secretValue="sk-ant-xxx" />,
|
||||
);
|
||||
fireEvent.click(document.querySelector('button[type="button"]')!);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(document.body.textContent).not.toContain("Connection timed out");
|
||||
expect(document.body.textContent).not.toContain("Service may be down");
|
||||
expect(document.body.textContent).toContain("not available");
|
||||
expect(document.body.textContent).toContain("not tested");
|
||||
});
|
||||
|
||||
it("reports a non-404 server error with its status, not a timeout", async () => {
|
||||
mockValidateSecret.mockRejectedValue(new ApiError(500, "Internal Server Error"));
|
||||
it("shows 'Connection timed out' on network error", async () => {
|
||||
mockValidateSecret.mockRejectedValue(new Error("timeout"));
|
||||
render(
|
||||
<TestConnectionButton provider="github" secretValue="ghp_xxx" />,
|
||||
);
|
||||
@@ -241,20 +210,7 @@ describe("TestConnectionButton — catch path", () => {
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(document.body.textContent).toContain("500");
|
||||
expect(document.body.textContent).not.toContain("Connection timed out");
|
||||
});
|
||||
|
||||
it("shows a connectivity message on a genuine network error", async () => {
|
||||
mockValidateSecret.mockRejectedValue(new Error("network down"));
|
||||
render(
|
||||
<TestConnectionButton provider="github" secretValue="ghp_xxx" />,
|
||||
);
|
||||
fireEvent.click(document.querySelector('button[type="button"]')!);
|
||||
await act(async () => {
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
});
|
||||
expect(document.body.textContent).toContain("Could not reach the validation service");
|
||||
expect(document.body.textContent).toContain("Connection timed out");
|
||||
});
|
||||
|
||||
it("calls onResult(false) on network error", async () => {
|
||||
|
||||
@@ -53,9 +53,10 @@ function makeStore(
|
||||
edges: Edge[] = [],
|
||||
selectedNodeId: string | null = null,
|
||||
agentMessages: Record<string, Array<{ id: string; content: string; timestamp: string }>> = {},
|
||||
liveAnnouncement = ""
|
||||
liveAnnouncement = "",
|
||||
broadcastMessages: Array<{ id: string; sender: string; senderId: string; message: string; timestamp: string }> = []
|
||||
) {
|
||||
const state = { nodes, edges, selectedNodeId, agentMessages, liveAnnouncement };
|
||||
const state = { nodes, edges, selectedNodeId, agentMessages, liveAnnouncement, broadcastMessages };
|
||||
const get = () => state;
|
||||
const set = vi.fn((partial: Record<string, unknown>) => {
|
||||
Object.assign(state, partial);
|
||||
@@ -1013,3 +1014,149 @@ describe("handleCanvasEvent – liveAnnouncement", () => {
|
||||
expect(state.liveAnnouncement ?? "").toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BROADCAST_MESSAGE
|
||||
//
|
||||
// Verifies that incoming org-wide broadcast WebSocket events are captured
|
||||
// in the store's broadcastMessages array and announced via liveAnnouncement
|
||||
// for screen readers. The Go platform already HTML-escaped the content at
|
||||
// broadcast time (OFFSEC-015 fix), so the handler renders it as-is.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe("handleCanvasEvent – BROADCAST_MESSAGE", () => {
|
||||
it("appends a broadcast message to broadcastMessages with correct fields", () => {
|
||||
const { get, set, state } = makeStore();
|
||||
|
||||
handleCanvasEvent(
|
||||
makeMsg({
|
||||
event: "BROADCAST_MESSAGE",
|
||||
workspace_id: "ws-sender",
|
||||
payload: {
|
||||
sender_id: "ws-ops",
|
||||
sender: "Ops Agent",
|
||||
message: "All systems go — deploy in 5 minutes",
|
||||
},
|
||||
}),
|
||||
get,
|
||||
set
|
||||
);
|
||||
|
||||
expect(set).toHaveBeenCalledOnce();
|
||||
const next = set.mock.calls[0][0] as { broadcastMessages: typeof state.broadcastMessages };
|
||||
expect(next.broadcastMessages).toHaveLength(1);
|
||||
expect(next.broadcastMessages[0].senderId).toBe("ws-ops");
|
||||
expect(next.broadcastMessages[0].sender).toBe("Ops Agent");
|
||||
expect(next.broadcastMessages[0].message).toBe("All systems go — deploy in 5 minutes");
|
||||
expect(next.broadcastMessages[0].id).toBeTruthy(); // crypto.randomUUID() called
|
||||
expect(next.broadcastMessages[0].timestamp).toBeTruthy();
|
||||
});
|
||||
|
||||
it("sets liveAnnouncement with sender and truncated message", () => {
|
||||
const { get, set } = makeStore();
|
||||
|
||||
handleCanvasEvent(
|
||||
makeMsg({
|
||||
event: "BROADCAST_MESSAGE",
|
||||
workspace_id: "ws-sender",
|
||||
payload: {
|
||||
sender_id: "ws-ops",
|
||||
sender: "Ops Agent",
|
||||
message: "Deploy starting now",
|
||||
},
|
||||
}),
|
||||
get,
|
||||
set
|
||||
);
|
||||
|
||||
const next = set.mock.calls[0][0] as { liveAnnouncement: string };
|
||||
expect(next.liveAnnouncement).toBe("Broadcast from Ops Agent: Deploy starting now");
|
||||
});
|
||||
|
||||
it("renders sender name as truncated ID when sender field is absent", () => {
|
||||
const { get, set, state } = makeStore();
|
||||
|
||||
handleCanvasEvent(
|
||||
makeMsg({
|
||||
event: "BROADCAST_MESSAGE",
|
||||
workspace_id: "ws-sender",
|
||||
payload: {
|
||||
sender_id: "ws-ops",
|
||||
message: "Deploy starting now",
|
||||
},
|
||||
}),
|
||||
get,
|
||||
set
|
||||
);
|
||||
|
||||
const next = set.mock.calls[0][0] as { broadcastMessages: typeof state.broadcastMessages };
|
||||
expect(next.broadcastMessages[0].sender).toBe("ws-ops".slice(0, 8)); // fallback: first 8 chars of ID
|
||||
});
|
||||
|
||||
it("is a no-op when message is empty string", () => {
|
||||
const { get, set } = makeStore();
|
||||
|
||||
handleCanvasEvent(
|
||||
makeMsg({
|
||||
event: "BROADCAST_MESSAGE",
|
||||
workspace_id: "ws-sender",
|
||||
payload: { sender_id: "ws-ops", sender: "Ops Agent", message: "" },
|
||||
}),
|
||||
get,
|
||||
set
|
||||
);
|
||||
|
||||
expect(set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("appends to existing broadcastMessages without replacing them", () => {
|
||||
const { get, set, state } = makeStore([], [], null, {}, "", [
|
||||
{
|
||||
id: "existing-1",
|
||||
senderId: "ws-old",
|
||||
sender: "Old Agent",
|
||||
message: "Previous broadcast",
|
||||
timestamp: "2026-05-14T12:00:00Z",
|
||||
},
|
||||
]);
|
||||
|
||||
handleCanvasEvent(
|
||||
makeMsg({
|
||||
event: "BROADCAST_MESSAGE",
|
||||
workspace_id: "ws-sender",
|
||||
payload: { sender_id: "ws-ops", sender: "Ops Agent", message: "New broadcast" },
|
||||
}),
|
||||
get,
|
||||
set
|
||||
);
|
||||
|
||||
const next = set.mock.calls[0][0] as { broadcastMessages: typeof state.broadcastMessages };
|
||||
expect(next.broadcastMessages).toHaveLength(2);
|
||||
expect(next.broadcastMessages[0].id).toBe("existing-1");
|
||||
expect(next.broadcastMessages[1].message).toBe("New broadcast");
|
||||
});
|
||||
|
||||
it("handles XSS-like content safely (content is pre-escaped by Go platform)", () => {
|
||||
const { get, set, state } = makeStore();
|
||||
|
||||
// The Go platform applied html.EscapeString before sending, so the handler
|
||||
// receives literal strings, not raw HTML. This test verifies no panic and
|
||||
// correct storage.
|
||||
handleCanvasEvent(
|
||||
makeMsg({
|
||||
event: "BROADCAST_MESSAGE",
|
||||
workspace_id: "ws-evil",
|
||||
payload: {
|
||||
sender_id: "ws-evil",
|
||||
sender: "Evil Sender",
|
||||
message: "<script>alert('xss')</script>",
|
||||
},
|
||||
}),
|
||||
get,
|
||||
set
|
||||
);
|
||||
|
||||
const next = set.mock.calls[0][0] as { broadcastMessages: typeof state.broadcastMessages };
|
||||
expect(next.broadcastMessages[0].message).toBe("<script>alert('xss')</script>");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1224,3 +1224,45 @@ describe("moveNode", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("useCanvasStore – broadcastMessages", () => {
|
||||
beforeEach(() => {
|
||||
useCanvasStore.setState({ broadcastMessages: [] });
|
||||
});
|
||||
|
||||
it("consumeBroadcastMessages returns and clears all messages", () => {
|
||||
useCanvasStore.setState({
|
||||
broadcastMessages: [
|
||||
{ id: "m1", senderId: "ws-1", sender: "Agent 1", message: "Hello", timestamp: "2026-05-16T00:00:00Z" },
|
||||
{ id: "m2", senderId: "ws-2", sender: "Agent 2", message: "World", timestamp: "2026-05-16T00:01:00Z" },
|
||||
],
|
||||
});
|
||||
const consumed = useCanvasStore.getState().consumeBroadcastMessages();
|
||||
expect(consumed).toHaveLength(2);
|
||||
expect(useCanvasStore.getState().broadcastMessages).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("dismissBroadcastMessage removes the targeted message only", () => {
|
||||
useCanvasStore.setState({
|
||||
broadcastMessages: [
|
||||
{ id: "m1", senderId: "ws-1", sender: "Agent 1", message: "Hello", timestamp: "2026-05-16T00:00:00Z" },
|
||||
{ id: "m2", senderId: "ws-2", sender: "Agent 2", message: "World", timestamp: "2026-05-16T00:01:00Z" },
|
||||
{ id: "m3", senderId: "ws-3", sender: "Agent 3", message: "Bye", timestamp: "2026-05-16T00:02:00Z" },
|
||||
],
|
||||
});
|
||||
useCanvasStore.getState().dismissBroadcastMessage("m2");
|
||||
const remaining = useCanvasStore.getState().broadcastMessages;
|
||||
expect(remaining).toHaveLength(2);
|
||||
expect(remaining.map((m) => m.id)).toEqual(["m1", "m3"]);
|
||||
});
|
||||
|
||||
it("dismissBroadcastMessage is idempotent for unknown IDs", () => {
|
||||
useCanvasStore.setState({
|
||||
broadcastMessages: [
|
||||
{ id: "m1", senderId: "ws-1", sender: "Agent 1", message: "Hello", timestamp: "2026-05-16T00:00:00Z" },
|
||||
],
|
||||
});
|
||||
expect(() => useCanvasStore.getState().dismissBroadcastMessage("nonexistent")).not.toThrow();
|
||||
expect(useCanvasStore.getState().broadcastMessages).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,7 +63,7 @@ class MockWebSocket {
|
||||
(globalThis as unknown as Record<string, unknown>).WebSocket = MockWebSocket;
|
||||
|
||||
// Now import the socket module (uses globalThis.WebSocket at call time)
|
||||
import { connectSocket, disconnectSocket, wakeSocket } from "../socket";
|
||||
import { connectSocket, disconnectSocket } from "../socket";
|
||||
import { useCanvasStore } from "../canvas";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -416,84 +416,3 @@ describe("RehydrateDedup", () => {
|
||||
expect(d.shouldSkip(2_700)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// wakeSocket() — visibility-wake reconnect (regression #223 / #228)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Mobile browsers (iOS Safari, Chrome on Android in deep-sleep) silently
|
||||
// drop the WebSocket when the tab is backgrounded; the in-page onclose
|
||||
// fires very late or never. Without a visibility wake, the canvas stays
|
||||
// frozen until the user manually refreshes.
|
||||
//
|
||||
// The real wiring lives at module level: connectSocket installs a
|
||||
// visibilitychange/pageshow listener that calls wake() on foreground.
|
||||
// We can't dispatch DOM events here because the suite runs under the
|
||||
// `node` test environment (no `document`/`window` — see canvas/vitest
|
||||
// .config.ts). Instead we test wake() directly through the wakeSocket
|
||||
// public export, which is the same code path the listener invokes.
|
||||
|
||||
describe("wakeSocket → reconnect (#223 / #228 — mobile visibility wake)", () => {
|
||||
it("wake on a healthy OPEN socket does not create a new WebSocket", () => {
|
||||
connectSocket();
|
||||
const ws = getLastWS();
|
||||
ws.triggerOpen();
|
||||
// OPEN === 1. wake() should take the healthy-no-op branch.
|
||||
(ws as unknown as { readyState: number }).readyState = 1;
|
||||
const before = MockWebSocket.instances.length;
|
||||
wakeSocket();
|
||||
expect(MockWebSocket.instances.length).toBe(before);
|
||||
});
|
||||
|
||||
it("wake on a CLOSED socket creates a new WebSocket (the actual #223 fix)", () => {
|
||||
connectSocket();
|
||||
const ws = getLastWS();
|
||||
ws.triggerOpen();
|
||||
// CLOSED === 3. Simulates the OS killing the socket while the tab
|
||||
// was backgrounded. We deliberately don't fire triggerClose() —
|
||||
// the whole point of #223 is that mobile browsers don't fire
|
||||
// onclose when they kill the WS, so reconnect never schedules.
|
||||
(ws as unknown as { readyState: number }).readyState = 3;
|
||||
const before = MockWebSocket.instances.length;
|
||||
wakeSocket();
|
||||
expect(MockWebSocket.instances.length).toBe(before + 1);
|
||||
});
|
||||
|
||||
it("wake while CONNECTING (readyState=0) does not pile another handshake", () => {
|
||||
connectSocket();
|
||||
const ws = getLastWS();
|
||||
// CONNECTING === 0 — a handshake is already in flight.
|
||||
(ws as unknown as { readyState: number }).readyState = 0;
|
||||
const before = MockWebSocket.instances.length;
|
||||
wakeSocket();
|
||||
expect(MockWebSocket.instances.length).toBe(before);
|
||||
});
|
||||
|
||||
it("wake cancels any pending backoff reconnect", () => {
|
||||
const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout");
|
||||
connectSocket();
|
||||
const ws = getLastWS();
|
||||
ws.triggerOpen();
|
||||
// Drop the socket — onclose schedules a backoff reconnect.
|
||||
ws.triggerClose();
|
||||
// Now wake the page. wake() should pre-empt the backoff so the
|
||||
// user sees the canvas come back immediately, not after the
|
||||
// exponential delay window.
|
||||
(ws as unknown as { readyState: number }).readyState = 3;
|
||||
clearTimeoutSpy.mockClear();
|
||||
wakeSocket();
|
||||
expect(clearTimeoutSpy).toHaveBeenCalled();
|
||||
clearTimeoutSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("wake after disconnectSocket is a no-op (no zombie reconnect)", () => {
|
||||
connectSocket();
|
||||
const ws = getLastWS();
|
||||
ws.triggerOpen();
|
||||
disconnectSocket();
|
||||
const before = MockWebSocket.instances.length;
|
||||
// Singleton is null now — wake() should silently do nothing.
|
||||
expect(() => wakeSocket()).not.toThrow();
|
||||
expect(MockWebSocket.instances.length).toBe(before);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -72,6 +72,7 @@ export function handleCanvasEvent(
|
||||
edges: Edge[];
|
||||
selectedNodeId: string | null;
|
||||
agentMessages: Record<string, Array<{ id: string; content: string; timestamp: string; attachments?: Array<{ name: string; uri: string; mimeType?: string; size?: number }> }>>;
|
||||
broadcastMessages: Array<{ id: string; sender: string; senderId: string; message: string; timestamp: string }>;
|
||||
},
|
||||
set: (partial: Record<string, unknown>) => void,
|
||||
): void {
|
||||
@@ -515,6 +516,34 @@ export function handleCanvasEvent(
|
||||
break;
|
||||
}
|
||||
|
||||
case "BROADCAST_MESSAGE": {
|
||||
// An agent workspace sent an org-wide broadcast. Display it as a
|
||||
// dismissible banner so the user is always aware of org-wide signals
|
||||
// even when no workspace is selected. The Go platform already HTML-
|
||||
// escaped the content at broadcast time (OFFSEC-015 fix), so it is
|
||||
// safe to render as innerText equivalent via dangerouslySetInnerHTML
|
||||
// is not needed — just render the string as-is.
|
||||
const senderId = (msg.payload.sender_id as string) ?? "";
|
||||
const sender = (msg.payload.sender as string) ?? senderId.slice(0, 8);
|
||||
const message = (msg.payload.message as string) ?? "";
|
||||
if (!message) break;
|
||||
const { broadcastMessages } = get();
|
||||
set({
|
||||
broadcastMessages: [
|
||||
...broadcastMessages,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
senderId,
|
||||
sender,
|
||||
message,
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
liveAnnouncement: `Broadcast from ${sender}: ${message}`,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -244,6 +244,13 @@ interface CanvasState {
|
||||
* so the same announcement doesn't re-fire on re-render. */
|
||||
liveAnnouncement: string;
|
||||
setLiveAnnouncement: (msg: string) => void;
|
||||
/** Incoming org-wide broadcast messages received via BROADCAST_MESSAGE
|
||||
* WebSocket events. Consumed by the BroadcastBanner component; each
|
||||
* entry is cleared after the user dismisses it so dismissed broadcasts
|
||||
* don't reappear on reconnect. */
|
||||
broadcastMessages: Array<{ id: string; sender: string; senderId: string; message: string; timestamp: string }>;
|
||||
consumeBroadcastMessages: () => Array<{ id: string; sender: string; senderId: string; message: string; timestamp: string }>;
|
||||
dismissBroadcastMessage: (id: string) => void;
|
||||
}
|
||||
|
||||
export const useCanvasStore = create<CanvasState>((set, get) => ({
|
||||
@@ -342,6 +349,14 @@ export const useCanvasStore = create<CanvasState>((set, get) => ({
|
||||
},
|
||||
liveAnnouncement: "",
|
||||
setLiveAnnouncement: (msg) => set({ liveAnnouncement: msg }),
|
||||
broadcastMessages: [],
|
||||
consumeBroadcastMessages: () => {
|
||||
const msgs = get().broadcastMessages;
|
||||
set({ broadcastMessages: [] });
|
||||
return msgs;
|
||||
},
|
||||
dismissBroadcastMessage: (id) =>
|
||||
set({ broadcastMessages: get().broadcastMessages.filter((m) => m.id !== id) }),
|
||||
|
||||
viewport: { x: 0, y: 0, zoom: 1 },
|
||||
|
||||
|
||||
@@ -268,46 +268,6 @@ class ReconnectingSocket {
|
||||
}
|
||||
useCanvasStore.getState().setWsStatus("disconnected");
|
||||
}
|
||||
|
||||
/** Force a reconnect attempt now, skipping the backoff window.
|
||||
* Used by the visibilitychange / pageshow handler: when a mobile
|
||||
* browser backgrounds the tab, the OS silently kills the WebSocket
|
||||
* but the in-page onclose either fires very late or never fires at
|
||||
* all (iOS Safari, Chrome on Android in deep-sleep). Once the user
|
||||
* brings the tab back, the canvas needs to reconnect within human
|
||||
* perception — not on whatever backoff delay was last scheduled,
|
||||
* which can be up to 30s. (#223 / #228)
|
||||
*
|
||||
* Idempotent: if the socket is already OPEN we leave it alone; the
|
||||
* WebSocket is still healthy and a reconnect would just churn. */
|
||||
wake() {
|
||||
if (this.disposed) return;
|
||||
// OPEN === 1. Use the numeric literal so we don't have to import
|
||||
// WebSocket type values; the runtime constant is well-defined.
|
||||
if (this.ws && this.ws.readyState === 1) {
|
||||
// Healthy. Run a rehydrate to catch any events we may have missed
|
||||
// while the tab was backgrounded — the OS does deliver some
|
||||
// packets late, but it can also drop them, and the dedup gate
|
||||
// collapses this with any subsequent health-check rehydrate.
|
||||
void this.rehydrate();
|
||||
return;
|
||||
}
|
||||
// CONNECTING === 0 means a handshake is already in flight. Don't
|
||||
// pile another one on; the existing attempt or its onclose-driven
|
||||
// reconnect will resolve.
|
||||
if (this.ws && this.ws.readyState === 0) return;
|
||||
// Otherwise (CLOSING, CLOSED, or null) we're in limbo. Cancel any
|
||||
// pending backoff and reconnect now.
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
// Reset attempt counter so the *next* failure (if any) starts from
|
||||
// a short delay again — we just had a real user interaction, not
|
||||
// an unattended-tab failure cascade.
|
||||
this.attempt = 0;
|
||||
this.connect();
|
||||
}
|
||||
}
|
||||
|
||||
export interface WorkspaceData {
|
||||
@@ -346,49 +306,11 @@ export interface WorkspaceData {
|
||||
|
||||
let socket: ReconnectingSocket | null = null;
|
||||
|
||||
/** visibilitychange / pageshow handler. Mobile browsers (iOS Safari,
|
||||
* Chrome on Android in deep-sleep) silently drop the WebSocket when
|
||||
* the tab is backgrounded — the in-page `onclose` fires very late or
|
||||
* never. Without this listener, the canvas appears frozen after the
|
||||
* user backgrounds the PWA and returns to it: status events, agent
|
||||
* messages, and cross-device chat broadcast don't arrive until a
|
||||
* manual refresh (#223 / #228).
|
||||
*
|
||||
* Both events are wired: `visibilitychange` covers tab-switch on a
|
||||
* live page; `pageshow` covers Safari's bfcache restore, where the
|
||||
* page comes back from cache without firing visibilitychange. */
|
||||
function onPageWake() {
|
||||
// document is undefined in SSR; the listener never installs there,
|
||||
// but defensively guard anyway in case this code is run via a test
|
||||
// harness that doesn't shim it.
|
||||
if (typeof document !== "undefined" && document.hidden) return;
|
||||
socket?.wake();
|
||||
}
|
||||
let visibilityHandlerInstalled = false;
|
||||
function installVisibilityHandler() {
|
||||
if (visibilityHandlerInstalled) return;
|
||||
if (typeof document === "undefined" || typeof window === "undefined") return;
|
||||
document.addEventListener("visibilitychange", onPageWake);
|
||||
// `pageshow` with `event.persisted === true` is the bfcache restore
|
||||
// signal — relevant on iOS Safari. We don't need to inspect
|
||||
// `persisted` because waking an OPEN socket is a no-op.
|
||||
window.addEventListener("pageshow", onPageWake);
|
||||
visibilityHandlerInstalled = true;
|
||||
}
|
||||
function uninstallVisibilityHandler() {
|
||||
if (!visibilityHandlerInstalled) return;
|
||||
if (typeof document === "undefined" || typeof window === "undefined") return;
|
||||
document.removeEventListener("visibilitychange", onPageWake);
|
||||
window.removeEventListener("pageshow", onPageWake);
|
||||
visibilityHandlerInstalled = false;
|
||||
}
|
||||
|
||||
export function connectSocket() {
|
||||
if (!socket) {
|
||||
socket = new ReconnectingSocket(WS_URL);
|
||||
}
|
||||
socket.connect();
|
||||
installVisibilityHandler();
|
||||
}
|
||||
|
||||
export function disconnectSocket() {
|
||||
@@ -396,14 +318,4 @@ export function disconnectSocket() {
|
||||
socket.disconnect();
|
||||
socket = null;
|
||||
}
|
||||
uninstallVisibilityHandler();
|
||||
}
|
||||
|
||||
/** Manually trigger the visibility-wake path. Exported so the test suite
|
||||
* can exercise `ReconnectingSocket.wake()` without depending on a
|
||||
* jsdom DOM (the rest of this file's tests run under the node env).
|
||||
* Real-world callers don't need this — the visibility/pageshow listener
|
||||
* drives it. */
|
||||
export function wakeSocket() {
|
||||
socket?.wake();
|
||||
}
|
||||
|
||||
@@ -584,10 +584,6 @@
|
||||
.secrets-tab__refresh-btn:hover {
|
||||
background: #1e40af;
|
||||
}
|
||||
.secrets-tab__refresh-btn:focus-visible {
|
||||
outline: 2px solid #1d4ed8;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.secrets-tab__no-results {
|
||||
text-align: center;
|
||||
@@ -653,10 +649,6 @@
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.delete-dialog__cancel-btn:focus-visible {
|
||||
outline: var(--focus-ring);
|
||||
outline-offset: var(--focus-ring-offset);
|
||||
}
|
||||
|
||||
.delete-dialog__confirm-btn {
|
||||
background: var(--status-invalid);
|
||||
@@ -666,10 +658,6 @@
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.delete-dialog__confirm-btn:focus-visible {
|
||||
outline: var(--focus-ring);
|
||||
outline-offset: var(--focus-ring-offset);
|
||||
}
|
||||
|
||||
.delete-dialog__confirm-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
|
||||
+4
-1
@@ -30,7 +30,10 @@
|
||||
{"name": "openclaw", "repo": "molecule-ai/molecule-ai-workspace-template-openclaw", "ref": "main"},
|
||||
{"name": "codex", "repo": "molecule-ai/molecule-ai-workspace-template-codex", "ref": "main"},
|
||||
{"name": "langgraph", "repo": "molecule-ai/molecule-ai-workspace-template-langgraph", "ref": "main"},
|
||||
{"name": "autogen", "repo": "molecule-ai/molecule-ai-workspace-template-autogen", "ref": "main"}
|
||||
{"name": "crewai", "repo": "molecule-ai/molecule-ai-workspace-template-crewai", "ref": "main"},
|
||||
{"name": "autogen", "repo": "molecule-ai/molecule-ai-workspace-template-autogen", "ref": "main"},
|
||||
{"name": "deepagents", "repo": "molecule-ai/molecule-ai-workspace-template-deepagents", "ref": "main"},
|
||||
{"name": "gemini-cli", "repo": "molecule-ai/molecule-ai-workspace-template-gemini-cli", "ref": "main"}
|
||||
],
|
||||
"org_templates": [
|
||||
{"name": "molecule-dev", "repo": "molecule-ai/molecule-ai-org-template-molecule-dev", "ref": "main"},
|
||||
|
||||
@@ -58,7 +58,6 @@ TOP_LEVEL_MODULES = {
|
||||
"a2a_response",
|
||||
"a2a_tools",
|
||||
"a2a_tools_delegation",
|
||||
"a2a_tools_identity",
|
||||
"a2a_tools_inbox",
|
||||
"a2a_tools_memory",
|
||||
"a2a_tools_messaging",
|
||||
@@ -311,17 +310,8 @@ locally.
|
||||
deps from your system Python. Plain `pip install --user` works
|
||||
but the binary lands in `~/.local/bin` (Linux) or
|
||||
`~/Library/Python/3.X/bin` (macOS) which is often not on PATH on
|
||||
a fresh shell — `claude mcp add molecule-<workspace-slug> -- molecule-mcp`
|
||||
then fails with "command not found" at first use.
|
||||
|
||||
* **Server name in `claude mcp add` is workspace-specific.** The
|
||||
Canvas "Add to Claude Code" snippet stamps a unique slug
|
||||
(`molecule-<workspace-name>`) so a single Claude Code session can
|
||||
talk to N molecule workspaces concurrently — `claude mcp add` keys
|
||||
entries by name in `~/.claude.json`, so re-running with a bare
|
||||
`molecule` name silently overwrites the prior workspace's entry.
|
||||
See [molecule-core#1535](https://git.moleculesai.app/molecule-ai/molecule-core/pulls/1535)
|
||||
for the canonical generator.
|
||||
a fresh shell — `claude mcp add molecule -- molecule-mcp` then
|
||||
fails with "command not found" at first use.
|
||||
|
||||
### Install
|
||||
|
||||
@@ -345,10 +335,8 @@ WORKSPACE_ID=<uuid> \\
|
||||
That exposes the same 8 platform tools (`delegate_task`, `list_peers`,
|
||||
`send_message_to_user`, `commit_memory`, etc.) that container-bound
|
||||
runtimes already get via the workspace's auto-spawned MCP. Register
|
||||
the binary in your agent's MCP config — use a workspace-specific
|
||||
server name so multi-workspace setups don't collide (e.g. Claude Code:
|
||||
`claude mcp add molecule-<workspace-slug> -- molecule-mcp` with the env
|
||||
above; the Canvas modal stamps the right slug for you).
|
||||
the binary in your agent's MCP config (e.g. Claude Code's
|
||||
`claude mcp add molecule -- molecule-mcp` with the env above).
|
||||
|
||||
### Keeping the token out of shell history
|
||||
|
||||
@@ -386,8 +374,8 @@ hold:
|
||||
wheel does (see `_build_initialize_result`). Nothing for you to
|
||||
do.
|
||||
2. **Claude Code installs the server as a marketplace plugin** — a
|
||||
plain `claude mcp add molecule-<workspace-slug> -- molecule-mcp`
|
||||
produces a non-plugin-sourced server, which Claude Code rejects with
|
||||
plain `claude mcp add molecule -- molecule-mcp` produces a
|
||||
non-plugin-sourced server, which Claude Code rejects with
|
||||
`channel_enable requires a marketplace plugin`. Until the
|
||||
official `moleculesai/claude-code-plugin` marketplace lands
|
||||
(tracking [#2936](https://git.moleculesai.app/molecule-ai/molecule-core/issues/2936)),
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
// Command t4-contract-dump prints the T4 privilege contract as YAML.
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// go run ./workspace-server/cmd/t4-contract-dump > t4_capabilities.yaml
|
||||
//
|
||||
// This is the seam that template-repo CI workflows consume:
|
||||
//
|
||||
// - Template CI fetches molecule-core at pinned ref
|
||||
// - Runs `go run ./workspace-server/cmd/t4-contract-dump` to produce
|
||||
// t4_capabilities.yaml
|
||||
// - Iterates capabilities and runs each Probe inside a freshly-built
|
||||
// privileged container
|
||||
// - Aggregates structured pass/fail; fails the gate on any hard miss.
|
||||
//
|
||||
// Keeping this trivial and pure-stdlib means a fork user does not need
|
||||
// a Molecule-AI Gitea token or any internal infrastructure to consume
|
||||
// the contract — `go run` against molecule-core's public source is
|
||||
// enough.
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/Molecule-AI/molecule-monorepo/platform/internal/provisioner"
|
||||
)
|
||||
|
||||
func main() {
|
||||
caps := provisioner.T4PrivilegeContract()
|
||||
if _, err := os.Stdout.WriteString(provisioner.AsYAML(caps)); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "t4-contract-dump: write failed:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
@@ -1,160 +0,0 @@
|
||||
package handlers
|
||||
|
||||
// Regression coverage for the POLL-mode arm of the canvas user-message
|
||||
// data-loss bug (internal#470 sibling — tracked on internal#471).
|
||||
//
|
||||
// Bug (reported 2026-05-16 by CTO Hongming): "in canvas i sometimes lose
|
||||
// my own message when i exit chat". The push-mode arm was fixed by
|
||||
// #1347 (persistUserMessageAtIngest — a SYNCHRONOUS, before-dispatch,
|
||||
// context.WithoutCancel INSERT). #1347's framing asserted "poll-mode
|
||||
// workspaces were never affected — logA2AReceiveQueued already persists
|
||||
// at ingest". That assertion is OVERSTATED.
|
||||
//
|
||||
// Hongming's tenant (slug `hongming`, org 2c940477-...) has 4 workspaces,
|
||||
// ALL runtime=external with empty URL → ALL delivery_mode=poll (proven
|
||||
// empirically: a benign A2A probe returns the synthetic
|
||||
// {"delivery_mode":"poll","status":"queued"} envelope for every one).
|
||||
// So his reported loss is the POLL path, NOT the push path #1347 fixes.
|
||||
//
|
||||
// Root cause (poll arm): the poll-mode short-circuit (a2a_proxy.go ~402)
|
||||
// calls logA2AReceiveQueued and then IMMEDIATELY returns the synthetic
|
||||
// 200 {status:"queued"} to the canvas. But logA2AReceiveQueued's durable
|
||||
// INSERT runs inside h.goAsync(...) — a DETACHED goroutine with NO
|
||||
// happens-before barrier against the HTTP response. The canvas sees 200
|
||||
// ("message accepted") while the activity_logs row may not yet be — and,
|
||||
// on a workspace-server restart / deploy / OOM / EC2 hibernation between
|
||||
// the 200 and the goroutine's commit, NEVER will be — durable. There is
|
||||
// also no fallback (unlike push-mode's legacy-INSERT fallback): a
|
||||
// swallowed LogActivity error loses the message with only a log line.
|
||||
// Chat-history reads activity_logs (postgres_store.go:165-187); a missing
|
||||
// row = message gone on reopen. That is exactly Hongming's symptom.
|
||||
//
|
||||
// Fix (parity with push-mode): the poll-mode ingest persist of the
|
||||
// canvas user message must be SYNCHRONOUS — committed before the queued
|
||||
// 200 is returned — on a context.WithoutCancel derived context, so a
|
||||
// client disconnect on chat-exit and a post-response restart cannot lose
|
||||
// it. Behavior is never worse than today (best-effort; a persist error
|
||||
// still returns queued).
|
||||
//
|
||||
// TEST DESIGN NOTE: sqlmock.ExpectationsWereMet() hangs indefinitely if
|
||||
// the expected query never fires. We use a select+default+time.After
|
||||
// pattern so the test FAILS fast (not hangs) when the production code
|
||||
// regresses to async (the INSERT never fires before handler returns),
|
||||
// while still returning promptly when all expectations are met. The
|
||||
// insertDelay is kept small (50ms) to minimise suite-level timing
|
||||
// impact under -race detection, where mock delays are amplified by
|
||||
// the instrumenter's goroutine overhead.
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// TestProxyA2A_PollMode_PersistsUserMessageSynchronouslyBeforeQueuedResponse
|
||||
// is the defining contract: for a poll-mode workspace, the canvas user
|
||||
// message MUST be durably INSERTed into activity_logs BEFORE the synthetic
|
||||
// queued 200 is returned to the client — with NO reliance on a detached
|
||||
// async goroutine completing later.
|
||||
//
|
||||
// The test proves the ordering by making the INSERT block briefly and
|
||||
// asserting the handler does NOT return until the INSERT has completed.
|
||||
// Pre-fix (INSERT in h.goAsync, response returned immediately) the
|
||||
// handler returns ~instantly while the INSERT is still pending in the
|
||||
// goroutine → the elapsed time is far below the injected INSERT delay and
|
||||
// ExpectationsWereMet() is racy/unmet at return. Post-fix (synchronous
|
||||
// persist before the queued response) the handler return is gated on the
|
||||
// INSERT, so elapsed >= the injected delay and the expectation is met
|
||||
// deterministically at return WITHOUT any waitAsyncForTest()/sleep.
|
||||
func TestProxyA2A_PollMode_PersistsUserMessageSynchronouslyBeforeQueuedResponse(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t)
|
||||
broadcaster := newTestBroadcaster()
|
||||
handler := NewWorkspaceHandler(broadcaster, nil, "http://localhost:8080", t.TempDir())
|
||||
|
||||
const wsID = "ws-poll-sync-persist"
|
||||
// Keep delay small: -race detection amplifies mock delays significantly.
|
||||
// A 50ms delay is sufficient to prove synchronous blocking (~50× the
|
||||
// normal INSERT latency) without bloating the full ./... suite runtime.
|
||||
const insertDelay = 50 * time.Millisecond
|
||||
|
||||
expectBudgetCheck(mock, wsID)
|
||||
|
||||
// lookupDeliveryMode → poll, triggering the short-circuit.
|
||||
mock.ExpectQuery("SELECT delivery_mode FROM workspaces WHERE id").
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"delivery_mode"}).AddRow("poll"))
|
||||
|
||||
// workspace-name lookup inside logA2AReceiveQueued.
|
||||
mock.ExpectQuery(`SELECT name FROM workspaces WHERE id`).
|
||||
WithArgs(wsID).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"name"}).AddRow("Poll WS"))
|
||||
|
||||
// The durable user-message write. We delay it so a synchronous
|
||||
// persist visibly gates the handler return; a detached-goroutine
|
||||
// persist (pre-fix) does not. The fix must keep using
|
||||
// context.WithoutCancel so this write survives a chat-exit cancel.
|
||||
mock.ExpectExec("INSERT INTO activity_logs").
|
||||
WillDelayFor(insertDelay).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Params = gin.Params{{Key: "id", Value: wsID}}
|
||||
|
||||
// callerID == "" (no X-Workspace-ID) → this is a canvas_user message,
|
||||
// exactly Hongming's case.
|
||||
body := `{"jsonrpc":"2.0","id":"poll-canvas-1","method":"message/send","params":{"message":{"role":"user","parts":[{"text":"my own message"}]}}}`
|
||||
c.Request = httptest.NewRequest("POST", "/workspaces/"+wsID+"/a2a", bytes.NewBufferString(body))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
|
||||
start := time.Now()
|
||||
handler.ProxyA2A(c)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
// Defining assertion #1: the handler must not have returned the
|
||||
// queued response before the durable INSERT committed. Pre-fix this
|
||||
// fails (elapsed ≈ 0, INSERT still racing in goAsync).
|
||||
if elapsed < insertDelay {
|
||||
t.Fatalf("poll-mode queued response returned in %v, before the %v user-message INSERT — "+
|
||||
"the message is not durable when the client/process goes away (DATA LOSS). "+
|
||||
"Persist must be synchronous before the queued 200.", elapsed, insertDelay)
|
||||
}
|
||||
|
||||
// Defining assertion #2: the durable write actually happened by the
|
||||
// time the handler returned. ExpectionsWereMet() hangs indefinitely if
|
||||
// the mock never fires (e.g. production code regressed to async),
|
||||
// so we check it in a goroutine with a hard 2s timeout — fails fast
|
||||
// (no CI hang) on regression while returning promptly on success.
|
||||
expectDone := make(chan error, 1)
|
||||
go func() { expectDone <- mock.ExpectationsWereMet() }()
|
||||
select {
|
||||
case err := <-expectDone:
|
||||
if err != nil {
|
||||
t.Fatalf("user-message INSERT was not durable at handler return (unmet sqlmock expectations): %v", err)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("ExpectationsWereMet() hung for >2s — INSERT mock never fired. " +
|
||||
"Likely cause: production code regressed logA2AReceiveQueued to goAsync " +
|
||||
"(INSERT fires after handler returns, not before).")
|
||||
}
|
||||
|
||||
// Sanity: still the correct poll-mode envelope + status.
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200 (queued), got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp map[string]interface{}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||
t.Fatalf("response is not valid JSON: %v", err)
|
||||
}
|
||||
if resp["status"] != "queued" || resp["delivery_mode"] != "poll" {
|
||||
t.Errorf("poll envelope changed: got status=%v delivery_mode=%v, want queued/poll",
|
||||
resp["status"], resp["delivery_mode"])
|
||||
}
|
||||
}
|
||||
@@ -399,21 +399,7 @@ func (h *WorkspaceHandler) proxyA2ARequest(ctx context.Context, workspaceID stri
|
||||
// (no Do(), no maybeMarkContainerDead). The response is a synthetic
|
||||
// {status:"queued"} envelope so the caller (canvas, another workspace)
|
||||
// knows delivery is acknowledged but pending consumption.
|
||||
deliveryMode, deliveryModeErr := lookupDeliveryMode(ctx, workspaceID)
|
||||
if deliveryModeErr != nil {
|
||||
// internal#497 fail-closed: a real DB/context error on the
|
||||
// delivery-mode read MUST NOT silently fall through to the push
|
||||
// dispatch path — that is exactly what silently misrouted every
|
||||
// poll-mode peer for 5 days under the ce2db75f regression. Surface
|
||||
// a structured error so the delegation is marked failed (loud +
|
||||
// retryable) instead of dispatched to the wrong path.
|
||||
log.Printf("ProxyA2A: delivery-mode lookup failed for %s: %v — failing closed", workspaceID, deliveryModeErr)
|
||||
return 0, nil, &proxyA2AError{
|
||||
Status: http.StatusServiceUnavailable,
|
||||
Response: gin.H{"error": "delivery-mode lookup failed; refusing to dispatch to avoid silent misrouting"},
|
||||
}
|
||||
}
|
||||
if deliveryMode == models.DeliveryModePoll {
|
||||
if lookupDeliveryMode(ctx, workspaceID) == models.DeliveryModePoll {
|
||||
if logActivity {
|
||||
h.logA2AReceiveQueued(ctx, workspaceID, callerID, body, a2aMethod)
|
||||
}
|
||||
|
||||
@@ -194,11 +194,6 @@ func (h *WorkspaceHandler) maybeMarkContainerDead(ctx context.Context, workspace
|
||||
}
|
||||
db.ClearWorkspaceKeys(ctx, workspaceID)
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceOffline), workspaceID, map[string]interface{}{})
|
||||
// Tracked via goAsync (not bare `go`) so the asyncWG can be drained
|
||||
// before a test swaps the global db.DB. runRestartCycle reads db.DB
|
||||
// before its provisioner gate, so an untracked detached goroutine
|
||||
// races setupTestDB's t.Cleanup db.DB restore. Matches the already-
|
||||
// correct site at a2a_proxy.go:648.
|
||||
h.goAsync(func() { h.RestartByID(workspaceID) })
|
||||
return true
|
||||
}
|
||||
@@ -246,9 +241,6 @@ func (h *WorkspaceHandler) preflightContainerHealth(ctx context.Context, workspa
|
||||
}
|
||||
db.ClearWorkspaceKeys(ctx, workspaceID)
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventWorkspaceOffline), workspaceID, map[string]interface{}{})
|
||||
// Tracked via goAsync (see maybeMarkContainerDead): preflight's
|
||||
// detached restart must be drainable so it doesn't race the global
|
||||
// db.DB swap in test cleanup.
|
||||
h.goAsync(func() { h.RestartByID(workspaceID) })
|
||||
return &proxyA2AError{
|
||||
Status: http.StatusServiceUnavailable,
|
||||
@@ -270,9 +262,8 @@ func (h *WorkspaceHandler) logA2AFailure(ctx context.Context, workspaceID, calle
|
||||
errWsName = workspaceID
|
||||
}
|
||||
summary := "A2A request to " + errWsName + " failed: " + errMsg
|
||||
parent := ctx
|
||||
h.goAsync(func() {
|
||||
logCtx, cancel := context.WithTimeout(context.WithoutCancel(parent), 30*time.Second)
|
||||
logCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
|
||||
defer cancel()
|
||||
LogActivity(logCtx, h.broadcaster, ActivityParams{
|
||||
WorkspaceID: workspaceID,
|
||||
@@ -318,9 +309,8 @@ func (h *WorkspaceHandler) logA2ASuccess(ctx context.Context, workspaceID, calle
|
||||
}
|
||||
summary := a2aMethod + " → " + wsNameForLog
|
||||
toolTrace := extractToolTrace(respBody)
|
||||
parent := ctx
|
||||
h.goAsync(func() {
|
||||
logCtx, cancel := context.WithTimeout(context.WithoutCancel(parent), 30*time.Second)
|
||||
logCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
|
||||
defer cancel()
|
||||
LogActivity(logCtx, h.broadcaster, ActivityParams{
|
||||
WorkspaceID: workspaceID,
|
||||
@@ -468,64 +458,40 @@ func parseUsageFromA2AResponse(body []byte) (inputTokens, outputTokens int64) {
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// lookupDeliveryMode returns the workspace's delivery_mode.
|
||||
//
|
||||
// internal#497 / RFC#497 fail-closed (SURGICAL scope): the *specific*
|
||||
// failure mode that hid the ce2db75f regression for 5 days is now
|
||||
// propagated instead of silently swallowed — a CONTEXT error
|
||||
// (context.Canceled / context.DeadlineExceeded). Under ce2db75f the
|
||||
// detached delegation goroutine ran on a cancelled request context, every
|
||||
// `SELECT delivery_mode` failed `context canceled`, this function returned
|
||||
// push, the poll-mode short-circuit in proxyA2ARequest was skipped, and
|
||||
// poll-mode peers (e.g. an operator laptop on molecule-mcp-claude-channel)
|
||||
// silently never got their a2a_receive inbox row. A transient,
|
||||
// systematic-once-triggered context cancellation became permanent
|
||||
// invisible misrouting. Returning that error lets the caller fail loud
|
||||
// (mark the delegation failed) instead of mis-dispatching.
|
||||
//
|
||||
// Scope is deliberately narrow: only ctx errors propagate. Other DB
|
||||
// errors retain the long-standing documented "fall back to push (today's
|
||||
// synchronous behavior)" contract — that path is loud + recoverable
|
||||
// (502 / SSRF reject / restart), unlike the silent poll-mode drop, and
|
||||
// the surrounding proxy (incl. the sibling checkWorkspaceBudget) is
|
||||
// intentionally built around that fail-open-to-push behavior. Widening
|
||||
// further is an RFC#497 follow-up, not part of this P0 fix.
|
||||
//
|
||||
// A genuinely *absent* configuration is NOT an error and still resolves to
|
||||
// push (the safe synchronous default): sql.ErrNoRows, a NULL/empty column,
|
||||
// or an unrecognised value all return (push, nil).
|
||||
// lookupDeliveryMode returns the workspace's delivery_mode. On any DB
|
||||
// error or missing row it returns DeliveryModePush — the fail-closed
|
||||
// default. "Closed" here means "fall back to today's behavior (synchronous
|
||||
// dispatch)" rather than "fall back to drop the request silently into
|
||||
// activity_logs where the agent might never see it." A poll-mode workspace
|
||||
// that briefly reads as push will get its A2A request dispatched to the
|
||||
// stored URL (or a 502 if no URL); a push-mode workspace that briefly
|
||||
// reads as poll would get its request silently queued with no dispatch.
|
||||
// The first failure is loud + recoverable; the second is silent.
|
||||
//
|
||||
// The function is intentionally lookup-only — it never mutates the row.
|
||||
// The register handler (registry.go) is the only writer for delivery_mode.
|
||||
//
|
||||
// See #2339 PR 1 for the column + register-flow side; this is the
|
||||
// proxy-side read used for the short-circuit in proxyA2ARequest.
|
||||
func lookupDeliveryMode(ctx context.Context, workspaceID string) (string, error) {
|
||||
func lookupDeliveryMode(ctx context.Context, workspaceID string) string {
|
||||
var mode sql.NullString
|
||||
err := db.DB.QueryRowContext(ctx,
|
||||
`SELECT delivery_mode FROM workspaces WHERE id = $1`, workspaceID,
|
||||
).Scan(&mode)
|
||||
if err != nil {
|
||||
// internal#497: a context cancellation/deadline MUST NOT be
|
||||
// swallowed into a silent push default — that is the exact 5-day
|
||||
// silent-misrouting vector. Propagate so the caller fails closed.
|
||||
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
|
||||
log.Printf("ProxyA2A: lookupDeliveryMode(%s) context error (%v) — failing closed (NOT defaulting to push)", workspaceID, err)
|
||||
return "", err
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
log.Printf("ProxyA2A: lookupDeliveryMode(%s) failed (%v) — defaulting to push (non-ctx DB error; legacy fail-open-to-push contract)", workspaceID, err)
|
||||
log.Printf("ProxyA2A: lookupDeliveryMode(%s) failed (%v) — defaulting to push", workspaceID, err)
|
||||
}
|
||||
return models.DeliveryModePush, nil
|
||||
return models.DeliveryModePush
|
||||
}
|
||||
if !mode.Valid || mode.String == "" {
|
||||
return models.DeliveryModePush, nil
|
||||
return models.DeliveryModePush
|
||||
}
|
||||
if !models.IsValidDeliveryMode(mode.String) {
|
||||
log.Printf("ProxyA2A: workspace %s has invalid delivery_mode=%q — defaulting to push", workspaceID, mode.String)
|
||||
return models.DeliveryModePush, nil
|
||||
return models.DeliveryModePush
|
||||
}
|
||||
return mode.String, nil
|
||||
return mode.String
|
||||
}
|
||||
|
||||
// logA2AReceiveQueued records a poll-mode "queued" A2A receive into
|
||||
@@ -538,49 +504,25 @@ func lookupDeliveryMode(ctx context.Context, workspaceID string) (string, error)
|
||||
// reads in PR 3 — that's how a poll-mode workspace receives inbound A2A
|
||||
// without a public URL.
|
||||
func (h *WorkspaceHandler) logA2AReceiveQueued(ctx context.Context, workspaceID, callerID string, body []byte, a2aMethod string) {
|
||||
// DATA-LOSS FIX (internal#471 — poll-mode sibling of #1347/internal#470):
|
||||
// this is the ONLY durable write of a poll-mode inbound message,
|
||||
// including a canvas_user message (callerID == "") typed in the canvas
|
||||
// chat. It MUST be SYNCHRONOUS and complete BEFORE the caller returns
|
||||
// the synthetic {status:"queued"} 200 — otherwise the canvas sees the
|
||||
// send acknowledged while the activity_logs row is still racing in a
|
||||
// detached goroutine, and a workspace-server restart / deploy / OOM /
|
||||
// EC2 hibernation between the 200 and the goroutine's commit loses the
|
||||
// user's message permanently (chat-history reads activity_logs, so a
|
||||
// missing row = message gone on reopen). Hongming's tenant is entirely
|
||||
// poll-mode (4 external workspaces, no URL — verified empirically), so
|
||||
// his reported loss is THIS path; #1347 (push-mode, persists AFTER the
|
||||
// poll short-circuit) structurally cannot cover it.
|
||||
//
|
||||
// Mirrors persistUserMessageAtIngest's discipline:
|
||||
// - context.WithoutCancel: a client disconnect on chat-exit (which
|
||||
// cancels the inbound request ctx) MUST NOT abort this write.
|
||||
// - SYNCHRONOUS (no goAsync): the row must be durable before the
|
||||
// queued 200 is returned to the caller.
|
||||
// - Best-effort: LogActivity already logs+swallows INSERT errors, so
|
||||
// a hiccup never blocks or fails the user's send (behavior for
|
||||
// that one request is never worse than the pre-fix async path).
|
||||
// The post-commit broadcast still fires inside LogActivity; a missed
|
||||
// WebSocket event is not data loss (the durable row is the truth the
|
||||
// canvas re-reads on reopen).
|
||||
insCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
var wsName string
|
||||
db.DB.QueryRowContext(insCtx, `SELECT name FROM workspaces WHERE id = $1`, workspaceID).Scan(&wsName)
|
||||
db.DB.QueryRowContext(ctx, `SELECT name FROM workspaces WHERE id = $1`, workspaceID).Scan(&wsName)
|
||||
if wsName == "" {
|
||||
wsName = workspaceID
|
||||
}
|
||||
summary := a2aMethod + " → " + wsName + " (queued for poll)"
|
||||
LogActivity(insCtx, h.broadcaster, ActivityParams{
|
||||
WorkspaceID: workspaceID,
|
||||
ActivityType: "a2a_receive",
|
||||
SourceID: nilIfEmpty(callerID),
|
||||
TargetID: &workspaceID,
|
||||
Method: &a2aMethod,
|
||||
Summary: &summary,
|
||||
RequestBody: json.RawMessage(body),
|
||||
Status: "ok",
|
||||
h.goAsync(func() {
|
||||
logCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
|
||||
defer cancel()
|
||||
LogActivity(logCtx, h.broadcaster, ActivityParams{
|
||||
WorkspaceID: workspaceID,
|
||||
ActivityType: "a2a_receive",
|
||||
SourceID: nilIfEmpty(callerID),
|
||||
TargetID: &workspaceID,
|
||||
Method: &a2aMethod,
|
||||
Summary: &summary,
|
||||
RequestBody: json.RawMessage(body),
|
||||
Status: "ok",
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -2235,18 +2235,12 @@ func TestProxyA2A_PushMode_NoShortCircuit(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestProxyA2A_PollMode_FailsClosedToPush verifies the LEGACY safety
|
||||
// contract is PRESERVED for non-context DB errors: a generic DB error
|
||||
// reading delivery_mode still defaults to push (today's behavior), NOT
|
||||
// poll. Failing to push means a poll-mode workspace briefly attempts a
|
||||
// real dispatch — visible failure (502 / SSRF rejection / restart
|
||||
// cascade), not a silent drop into activity_logs where the agent might
|
||||
// never look. Loud > silent, recoverable > lost.
|
||||
//
|
||||
// internal#497 narrows the fail-closed change to *context* errors only
|
||||
// (the actual ce2db75f regression vector); generic DB errors keep this
|
||||
// long-standing fail-open-to-push contract. The ctx-error fail-closed is
|
||||
// covered by TestLookupDeliveryMode_ContextCanceled_FailsClosed.
|
||||
// TestProxyA2A_PollMode_FailsClosedToPush verifies the safety contract:
|
||||
// a DB error reading delivery_mode must default to push (the existing
|
||||
// behavior), NOT poll. Failing to push means a poll-mode workspace
|
||||
// briefly attempts a real dispatch — visible failure (502 / SSRF
|
||||
// rejection / restart cascade), not a silent drop into activity_logs
|
||||
// where the agent might never look. Loud > silent, recoverable > lost.
|
||||
func TestProxyA2A_PollMode_FailsClosedToPush(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
setupTestRedis(t) // empty Redis — forces resolveAgentURL DB lookup
|
||||
@@ -2257,8 +2251,7 @@ func TestProxyA2A_PollMode_FailsClosedToPush(t *testing.T) {
|
||||
|
||||
expectBudgetCheck(mock, wsID)
|
||||
|
||||
// lookupDeliveryMode hits a generic (non-context) DB error → must
|
||||
// still default push (legacy contract preserved by internal#497).
|
||||
// lookupDeliveryMode hits a transient DB error → must default push.
|
||||
mock.ExpectQuery("SELECT delivery_mode FROM workspaces WHERE id").
|
||||
WithArgs(wsID).
|
||||
WillReturnError(sql.ErrConnDone)
|
||||
@@ -2282,7 +2275,7 @@ func TestProxyA2A_PollMode_FailsClosedToPush(t *testing.T) {
|
||||
var resp map[string]interface{}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &resp)
|
||||
if resp["status"] == "queued" {
|
||||
t.Errorf("generic DB error on delivery_mode lookup silently queued the request — must fail-open-to-push, got body: %s", w.Body.String())
|
||||
t.Errorf("DB error on delivery_mode lookup silently queued the request — must fail-closed-to-push, got body: %s", w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2291,37 +2284,6 @@ func TestProxyA2A_PollMode_FailsClosedToPush(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestLookupDeliveryMode_ContextCanceled_FailsClosed is the internal#497
|
||||
// regression test for the SECONDARY defect. It pins the exact invariant
|
||||
// that hid the ce2db75f regression for 5 days: when the delivery_mode read
|
||||
// fails because the context was cancelled (precisely what happened in the
|
||||
// detached delegation goroutine running on a returned request context),
|
||||
// lookupDeliveryMode MUST return an error and MUST NOT silently return
|
||||
// "push". Returning push there is what skipped the poll-mode short-circuit
|
||||
// and silently dropped 100% of poll-mode peer deliveries.
|
||||
//
|
||||
// A pre-cancelled context makes QueryRowContext fail with
|
||||
// context.Canceled deterministically — no DB rows are mocked because the
|
||||
// query never reaches a result.
|
||||
func TestLookupDeliveryMode_ContextCanceled_FailsClosed(t *testing.T) {
|
||||
mock := setupTestDB(t)
|
||||
// The query fails on the cancelled ctx before matching; provide a
|
||||
// permissive expectation so sqlmock doesn't complain about the attempt.
|
||||
mock.ExpectQuery("SELECT delivery_mode FROM workspaces WHERE id").
|
||||
WillReturnError(context.Canceled)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // simulate the HTTP handler having returned (request ctx dead)
|
||||
|
||||
mode, err := lookupDeliveryMode(ctx, "ws-poll-peer")
|
||||
if err == nil {
|
||||
t.Fatalf("internal#497 regression: lookupDeliveryMode swallowed a context error and returned mode=%q with nil err — this is the exact 5-day silent-misrouting vector", mode)
|
||||
}
|
||||
if mode == models.DeliveryModePush {
|
||||
t.Errorf("internal#497 regression: context error must NOT default to push (got mode=%q)", mode)
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== a2aClient ResponseHeaderTimeout config ====================
|
||||
|
||||
func TestA2AClientResponseHeaderTimeout(t *testing.T) {
|
||||
|
||||
@@ -44,8 +44,8 @@ func NewWorkspaceImageService(docker *dockerclient.Client) *WorkspaceImageServic
|
||||
// AllRuntimes is the canonical list mirroring docs/workspace-runtime-package.md.
|
||||
// Update both when a new template is added.
|
||||
var AllRuntimes = []string{
|
||||
"claude-code", "langgraph", "autogen",
|
||||
"hermes", "openclaw",
|
||||
"claude-code", "langgraph", "crewai", "autogen",
|
||||
"deepagents", "hermes", "gemini-cli", "openclaw",
|
||||
}
|
||||
|
||||
// RefreshResult is the per-call outcome surfaced to HTTP callers AND logged
|
||||
|
||||
@@ -1,113 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// agent_card_reconcile.go — server-side repair for the fleet-wide
|
||||
// agent-card identity gap.
|
||||
//
|
||||
// Root cause: the runtime builds its AgentCard from config.name
|
||||
// (workspace/main.py:198), and config.name is read from the
|
||||
// CP-regenerated /configs/config.yaml whose `name:` field is the raw
|
||||
// workspace UUID — NOT the friendly name the operator sees. The friendly
|
||||
// name IS captured: POST /workspaces and PATCH /workspaces/:id (the
|
||||
// canvas Details tab) write it to the trusted workspaces.name DB column.
|
||||
// But /registry/register stores the runtime-supplied card verbatim
|
||||
// (registry.go: `agent_card = EXCLUDED.agent_card`), so the stored card
|
||||
// served at /.well-known/agent-card.json and returned to peers via
|
||||
// agent_card_url ends up with name = UUID, description = "", role = null.
|
||||
//
|
||||
// Fix shape (deliberately minimal, no contract weakening): when the
|
||||
// runtime-supplied card's `name` is empty or equals the workspace UUID
|
||||
// (the placeholder the runtime had no better value for), the PLATFORM —
|
||||
// not the agent — substitutes the friendly value from the trusted
|
||||
// workspaces row. Identity stays platform-controlled: the agent never
|
||||
// gains the ability to self-set its own name/role; the platform sources
|
||||
// it from the operator-controlled DB column. We only ever FILL gaps
|
||||
// (empty / UUID-placeholder); a card that already carries a real
|
||||
// friendly name is never downgraded.
|
||||
//
|
||||
// list_peers / the /registry/:id/peers endpoint already resolve display
|
||||
// names from workspaces.name directly (discovery.go / mcp_tools.go
|
||||
// `SELECT w.id, w.name, ...`), so peer_name in delivered message tags
|
||||
// was already correct — this fix closes the remaining surface: the
|
||||
// agent_card blob itself (canvas Agent Card / Skills view, peer
|
||||
// agent_card_url fetches, the well-known card).
|
||||
//
|
||||
// description / role degrade discovery the same way: an empty
|
||||
// description and null role give peers nothing to reason about. We
|
||||
// default description from the (now reconciled) name when blank and
|
||||
// role from workspaces.role when the operator set one.
|
||||
|
||||
// reconcileAgentCardIdentity patches identity gaps in a runtime-supplied
|
||||
// agent card from the trusted workspace DB row. It returns the
|
||||
// (possibly rewritten) card bytes and whether anything changed. On any
|
||||
// failure (malformed JSON, nothing to fill) it returns the input bytes
|
||||
// unchanged with changed=false so the caller can store them verbatim —
|
||||
// this is strictly no-worse-than-before, never a regression.
|
||||
//
|
||||
// Pure function: no DB / HTTP / globals, so it is exhaustively
|
||||
// unit-testable (agent_card_reconcile_test.go) without booting the
|
||||
// handler or a sqlmock.
|
||||
func reconcileAgentCardIdentity(card json.RawMessage, workspaceID, dbName, dbRole string) (json.RawMessage, bool) {
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(card, &m); err != nil || m == nil {
|
||||
// Malformed card — not this function's job to reject it (the
|
||||
// upsert stores it as-is and downstream readers handle bad
|
||||
// JSON). Return verbatim so byte-for-byte behaviour is
|
||||
// preserved on the failure path.
|
||||
return card, false
|
||||
}
|
||||
|
||||
changed := false
|
||||
|
||||
// name: fill only when empty or the UUID placeholder. A dbName that
|
||||
// is itself the UUID is a placeholder row (registry.go INSERT seeds
|
||||
// name = id before the canvas sets a friendly one) — not a friendly
|
||||
// name, so it is not an eligible source.
|
||||
cardName, _ := m["name"].(string)
|
||||
if (cardName == "" || cardName == workspaceID) &&
|
||||
dbName != "" && dbName != workspaceID {
|
||||
m["name"] = dbName
|
||||
changed = true
|
||||
}
|
||||
|
||||
// description: when blank, default to the (reconciled) name so peers
|
||||
// and the canvas Agent Card view have a non-empty human label
|
||||
// instead of "". Mirrors the runtime's own
|
||||
// `config.description or config.name` fallback (main.py:199) but
|
||||
// applied to the registry copy where the runtime's fallback was the
|
||||
// UUID.
|
||||
if desc, _ := m["description"].(string); desc == "" {
|
||||
if n, _ := m["name"].(string); n != "" && n != workspaceID {
|
||||
m["description"] = n
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
// role: surface the operator-set workspaces.role when the card
|
||||
// carries none. Discovery (peer_role) and the canvas Role row read
|
||||
// workspaces.role directly; this just makes the standalone card
|
||||
// self-describing too. Never overwrite a role the card already has.
|
||||
if dbRole != "" {
|
||||
if r, ok := m["role"].(string); !ok || r == "" {
|
||||
m["role"] = dbRole
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
if !changed {
|
||||
// No-op: return the original bytes untouched so callers that
|
||||
// compare/store get byte-identical input (re-marshalling would
|
||||
// reorder keys for no reason).
|
||||
return card, false
|
||||
}
|
||||
|
||||
out, err := json.Marshal(m)
|
||||
if err != nil {
|
||||
// Re-marshal of a map we just unmarshalled should never fail;
|
||||
// if it somehow does, fall back to the verbatim input rather
|
||||
// than storing nothing.
|
||||
return card, false
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestReconcileAgentCardIdentity covers the server-side backfill that
|
||||
// repairs the fleet-wide agent-card identity gap (internal#XXX): the
|
||||
// runtime POSTs /registry/register with agent_card.name = the workspace
|
||||
// UUID (because the CP-regenerated /configs/config.yaml sets name: <uuid>)
|
||||
// while the trusted workspaces.name DB column — the value the canvas
|
||||
// Details tab shows and lets the operator edit — holds the friendly
|
||||
// name ("Claude Code Agent"). The platform reconciles them from the DB
|
||||
// row (NOT from the agent — identity stays platform-controlled, not
|
||||
// self-mutable).
|
||||
func TestReconcileAgentCardIdentity(t *testing.T) {
|
||||
const wsID = "3b81321b-1ec7-488c-96f7-72c42a968da6"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
card string
|
||||
dbName string
|
||||
dbRole string
|
||||
wantName string
|
||||
wantDesc string
|
||||
wantRole string
|
||||
wantChanged bool
|
||||
}{
|
||||
{
|
||||
name: "name is the workspace UUID — backfill from DB",
|
||||
card: `{"name":"3b81321b-1ec7-488c-96f7-72c42a968da6","description":"","capabilities":{"streaming":true}}`,
|
||||
dbName: "Claude Code Agent",
|
||||
dbRole: "",
|
||||
wantName: "Claude Code Agent",
|
||||
wantDesc: "Claude Code Agent",
|
||||
wantRole: "",
|
||||
wantChanged: true,
|
||||
},
|
||||
{
|
||||
name: "empty name — backfill from DB",
|
||||
card: `{"name":"","description":"x"}`,
|
||||
dbName: "ops-agent",
|
||||
dbRole: "sre",
|
||||
wantName: "ops-agent",
|
||||
wantDesc: "x",
|
||||
wantRole: "sre",
|
||||
wantChanged: true,
|
||||
},
|
||||
{
|
||||
name: "role null in card, DB has role — backfill role only",
|
||||
card: `{"name":"Reviewer","description":"Senior reviewer"}`,
|
||||
dbName: "Reviewer",
|
||||
dbRole: "code-reviewer",
|
||||
wantName: "Reviewer",
|
||||
wantDesc: "Senior reviewer",
|
||||
wantRole: "code-reviewer",
|
||||
wantChanged: true,
|
||||
},
|
||||
{
|
||||
name: "card already has a real friendly name — do NOT clobber it",
|
||||
// A richer card (e.g. an external channel agent) must win;
|
||||
// the platform only fills gaps, never downgrades.
|
||||
card: `{"name":"Claude Code (channel)","description":"Local Claude Code session bridged","role":"assistant"}`,
|
||||
dbName: "hongming-pc",
|
||||
dbRole: "operator",
|
||||
wantName: "Claude Code (channel)",
|
||||
wantDesc: "Local Claude Code session bridged",
|
||||
wantRole: "assistant",
|
||||
wantChanged: false,
|
||||
},
|
||||
{
|
||||
name: "no DB name available — leave UUID name untouched (no worse than before)",
|
||||
card: `{"name":"3b81321b-1ec7-488c-96f7-72c42a968da6","description":""}`,
|
||||
dbName: "",
|
||||
dbRole: "",
|
||||
wantName: "3b81321b-1ec7-488c-96f7-72c42a968da6",
|
||||
wantDesc: "",
|
||||
wantRole: "",
|
||||
wantChanged: false,
|
||||
},
|
||||
{
|
||||
name: "dbName equals UUID (placeholder row) — not a friendly name, leave untouched",
|
||||
card: `{"name":"3b81321b-1ec7-488c-96f7-72c42a968da6"}`,
|
||||
dbName: "3b81321b-1ec7-488c-96f7-72c42a968da6",
|
||||
dbRole: "",
|
||||
wantName: "3b81321b-1ec7-488c-96f7-72c42a968da6",
|
||||
wantDesc: "",
|
||||
wantRole: "",
|
||||
wantChanged: false,
|
||||
},
|
||||
{
|
||||
name: "malformed card JSON — return unchanged, no panic",
|
||||
card: `{not json`,
|
||||
dbName: "Claude Code Agent",
|
||||
dbRole: "",
|
||||
wantChanged: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
out, changed := reconcileAgentCardIdentity(
|
||||
json.RawMessage(tc.card), wsID, tc.dbName, tc.dbRole,
|
||||
)
|
||||
if changed != tc.wantChanged {
|
||||
t.Fatalf("changed = %v, want %v", changed, tc.wantChanged)
|
||||
}
|
||||
if !tc.wantChanged {
|
||||
// Unchanged path must return the input bytes verbatim.
|
||||
if string(out) != tc.card {
|
||||
t.Fatalf("unchanged path mutated bytes:\n got %s\n want %s", out, tc.card)
|
||||
}
|
||||
return
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(out, &got); err != nil {
|
||||
t.Fatalf("output not valid JSON: %v (%s)", err, out)
|
||||
}
|
||||
if g, _ := got["name"].(string); g != tc.wantName {
|
||||
t.Errorf("name = %q, want %q", g, tc.wantName)
|
||||
}
|
||||
if g, _ := got["description"].(string); g != tc.wantDesc {
|
||||
t.Errorf("description = %q, want %q", g, tc.wantDesc)
|
||||
}
|
||||
if tc.wantRole != "" {
|
||||
if g, _ := got["role"].(string); g != tc.wantRole {
|
||||
t.Errorf("role = %q, want %q", g, tc.wantRole)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestReconcileAgentCardIdentity_PreservesOtherFields ensures the
|
||||
// reconcile is a minimal in-place patch — capabilities, version,
|
||||
// skills and any unknown future fields survive untouched.
|
||||
func TestReconcileAgentCardIdentity_PreservesOtherFields(t *testing.T) {
|
||||
card := `{"name":"ws-uuid","description":"","version":"1.0.0",` +
|
||||
`"capabilities":{"streaming":true,"pushNotifications":true},` +
|
||||
`"skills":[{"id":"a","name":"a"}],"configuration_status":"ready"}`
|
||||
out, changed := reconcileAgentCardIdentity(
|
||||
json.RawMessage(card), "ws-uuid", "Friendly Name", "",
|
||||
)
|
||||
if !changed {
|
||||
t.Fatal("expected changed = true")
|
||||
}
|
||||
var got map[string]any
|
||||
if err := json.Unmarshal(out, &got); err != nil {
|
||||
t.Fatalf("invalid JSON: %v", err)
|
||||
}
|
||||
if got["version"] != "1.0.0" {
|
||||
t.Errorf("version not preserved: %v", got["version"])
|
||||
}
|
||||
if got["configuration_status"] != "ready" {
|
||||
t.Errorf("configuration_status not preserved: %v", got["configuration_status"])
|
||||
}
|
||||
caps, ok := got["capabilities"].(map[string]any)
|
||||
if !ok || caps["streaming"] != true {
|
||||
t.Errorf("capabilities not preserved: %v", got["capabilities"])
|
||||
}
|
||||
skills, ok := got["skills"].([]any)
|
||||
if !ok || len(skills) != 1 {
|
||||
t.Errorf("skills not preserved: %v", got["skills"])
|
||||
}
|
||||
}
|
||||
@@ -17,17 +17,6 @@ var gitIdentitySlugPattern = regexp.MustCompile(`[^a-z0-9]+`)
|
||||
// docs/authorship.md (when it exists).
|
||||
const gitIdentityEmailDomain = "agents.moleculesai.app"
|
||||
|
||||
// gitAskpassHelperPath is the in-container path of the askpass helper
|
||||
// installed by every workspace runtime image (workspace/Dockerfile in
|
||||
// molecule-core; scripts/git-askpass.sh → /usr/local/bin/molecule-askpass
|
||||
// in each external template-* repo). The helper reads GIT_HTTP_USERNAME
|
||||
// / GIT_HTTP_PASSWORD (falling back to GITEA_USER / GITEA_TOKEN) from
|
||||
// env and emits them on the git credential-prompt protocol. Setting
|
||||
// GIT_ASKPASS to this path is what wires container-side HTTPS git auth
|
||||
// to the persona credentials already arriving via workspace_secrets,
|
||||
// with no on-disk .gitconfig / .git-credentials mutation required.
|
||||
const gitAskpassHelperPath = "/usr/local/bin/molecule-askpass"
|
||||
|
||||
// applyAgentGitIdentity sets GIT_AUTHOR_* / GIT_COMMITTER_* env vars so
|
||||
// every commit from this workspace container carries a distinct author
|
||||
// in `git log` and `git blame`. Git reads these env vars before falling
|
||||
@@ -61,34 +50,6 @@ func applyAgentGitIdentity(envVars map[string]string, workspaceName string) {
|
||||
setIfEmpty(envVars, "GIT_AUTHOR_EMAIL", authorEmail)
|
||||
setIfEmpty(envVars, "GIT_COMMITTER_NAME", authorName)
|
||||
setIfEmpty(envVars, "GIT_COMMITTER_EMAIL", authorEmail)
|
||||
|
||||
applyGitAskpass(envVars)
|
||||
}
|
||||
|
||||
// applyGitAskpass points git at the in-image askpass helper so that any
|
||||
// HTTPS git operation against a remote without a pre-configured
|
||||
// credential.helper picks up the persona credentials already present in
|
||||
// the container env (GIT_HTTP_USERNAME / GIT_HTTP_PASSWORD, or
|
||||
// GITEA_USER / GITEA_TOKEN as fallback — the latter pair is what
|
||||
// loadPersonaEnvFile delivers from the operator-host bootstrap kit).
|
||||
//
|
||||
// Idempotent: if GIT_ASKPASS is already set (e.g. by an operator-
|
||||
// supplied workspace_secret or an env-mutator plugin), the existing
|
||||
// value wins. This lets a workspace opt out by setting GIT_ASKPASS=""
|
||||
// or pointing at a different helper.
|
||||
//
|
||||
// No vendor-specific behaviour lives in this function — the host the
|
||||
// credentials apply to is determined entirely by the deployer choosing
|
||||
// when to populate GIT_HTTP_USERNAME / GIT_HTTP_PASSWORD (or
|
||||
// GITEA_USER / GITEA_TOKEN). The helper script itself is generic and
|
||||
// has no hardcoded hostnames, so it's safe to ship inside the
|
||||
// open-source workspace template images alongside the platform-managed
|
||||
// claude-code image.
|
||||
func applyGitAskpass(envVars map[string]string) {
|
||||
if envVars == nil {
|
||||
return
|
||||
}
|
||||
setIfEmpty(envVars, "GIT_ASKPASS", gitAskpassHelperPath)
|
||||
}
|
||||
|
||||
// slugifyForEmail collapses a workspace name to a safe email localpart:
|
||||
|
||||
@@ -75,53 +75,6 @@ func TestApplyAgentGitIdentity_NilMapIsSafe(t *testing.T) {
|
||||
applyAgentGitIdentity(nil, "PM")
|
||||
}
|
||||
|
||||
func TestApplyAgentGitIdentity_SetsGitAskpass(t *testing.T) {
|
||||
// GIT_ASKPASS is what wires container-side HTTPS git auth to the
|
||||
// persona credentials (GITEA_USER/GITEA_TOKEN, etc.) that
|
||||
// loadPersonaEnvFile delivers via workspace_secrets. Without this,
|
||||
// `git push` inside the container would fall through to interactive
|
||||
// prompts (impossible) or a missing credential.helper (401).
|
||||
env := map[string]string{}
|
||||
applyAgentGitIdentity(env, "Frontend Engineer")
|
||||
if env["GIT_ASKPASS"] != "/usr/local/bin/molecule-askpass" {
|
||||
t.Errorf("GIT_ASKPASS: got %q, want %q",
|
||||
env["GIT_ASKPASS"], "/usr/local/bin/molecule-askpass")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAgentGitIdentity_RespectsAskpassOverride(t *testing.T) {
|
||||
// A workspace_secret or env-mutator plugin must be able to point at
|
||||
// a custom askpass helper without us clobbering it. Symmetric with
|
||||
// the GIT_AUTHOR_NAME override test above.
|
||||
env := map[string]string{
|
||||
"GIT_ASKPASS": "/opt/custom/askpass",
|
||||
}
|
||||
applyAgentGitIdentity(env, "Backend Engineer")
|
||||
if env["GIT_ASKPASS"] != "/opt/custom/askpass" {
|
||||
t.Errorf("GIT_ASKPASS should not be overwritten, got %q", env["GIT_ASKPASS"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyAgentGitIdentity_AskpassSkippedOnEmptyName(t *testing.T) {
|
||||
// The empty-name early-return covers GIT_ASKPASS too — a provisioning
|
||||
// glitch that dropped the workspace name shouldn't half-configure the
|
||||
// container (identity vars empty but askpass wired). All-or-nothing.
|
||||
env := map[string]string{}
|
||||
applyAgentGitIdentity(env, "")
|
||||
if _, ok := env["GIT_ASKPASS"]; ok {
|
||||
t.Errorf("empty name should not set GIT_ASKPASS, got %q", env["GIT_ASKPASS"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyGitAskpass_NilMapIsSafe(t *testing.T) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
t.Errorf("applyGitAskpass panicked on nil map: %v", r)
|
||||
}
|
||||
}()
|
||||
applyGitAskpass(nil)
|
||||
}
|
||||
|
||||
func TestSlugifyForEmail(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, want string
|
||||
|
||||
@@ -163,32 +163,8 @@ func (h *DelegationHandler) Delegate(c *gin.Context) {
|
||||
},
|
||||
})
|
||||
|
||||
// Fire-and-forget: send A2A in a background goroutine.
|
||||
//
|
||||
// internal#497 — the goroutine MUST NOT inherit the HTTP request's
|
||||
// cancellation. `ctx` here is c.Request.Context(); the handler returns
|
||||
// 202 a few lines below, which cancels that context immediately. Before
|
||||
// this fix (regression ce2db75f) executeDelegation ran on the
|
||||
// request-scoped ctx, so every DB op + proxy call in the detached
|
||||
// goroutine failed `context canceled` the instant the 202 was written.
|
||||
// That silently broke 100% of A2A peer delegations fleet-wide since
|
||||
// 2026-05-12 (poll-mode peers never got their a2a_receive inbox row;
|
||||
// lookupDeliveryMode swallowed the ctx error and defaulted to push).
|
||||
//
|
||||
// context.WithoutCancel detaches cancellation/deadline while PRESERVING
|
||||
// all context values (trace/correlation/tenant ids that proxyA2ARequest
|
||||
// and the broadcaster read off ctx) — this is the established pattern in
|
||||
// this package (a2a_proxy.go:850, a2a_proxy_helpers.go:525,
|
||||
// registry.go:822). The 30-minute ceiling matches the prior internal
|
||||
// budget executeDelegation used before ce2db75f and the proxy's own
|
||||
// absolute agent-dispatch ceiling (a2a_proxy.go forwardCtx).
|
||||
delegationCtx, cancelDelegation := context.WithTimeout(
|
||||
context.WithoutCancel(ctx), 30*time.Minute,
|
||||
)
|
||||
go func() {
|
||||
defer cancelDelegation()
|
||||
h.executeDelegation(delegationCtx, sourceID, body.TargetID, delegationID, a2aBody)
|
||||
}()
|
||||
// Fire-and-forget: send A2A in background goroutine
|
||||
go h.executeDelegation(ctx, sourceID, body.TargetID, delegationID, a2aBody)
|
||||
|
||||
// Broadcast event so canvas shows delegation in real-time
|
||||
h.broadcaster.RecordAndBroadcast(ctx, string(events.EventDelegationSent), sourceID, map[string]interface{}{
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user