Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 80f949a528 | |||
| 977a3a7c9c | |||
| a338fa3807 | |||
| 8772fa5433 | |||
| 03540edaa4 | |||
| a9c18e066d | |||
| 3dbaf4bcc8 | |||
| b935e48449 | |||
| 4c0cd6b705 | |||
| af7afc6112 | |||
| dc858ad164 | |||
| 2ffd44c694 | |||
| 4f5d683f4b | |||
| df4a0e3f9d | |||
| c3cfbea750 | |||
| a01d1d8f86 |
@@ -65,6 +65,11 @@ 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
|
||||
@@ -148,15 +153,38 @@ 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
|
||||
|
||||
@@ -209,6 +237,7 @@ 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
|
||||
@@ -228,7 +257,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)
|
||||
ok, missing_or_bad = required_contexts_green(latest, required_contexts, pr_labels)
|
||||
if not ok:
|
||||
return MergeDecision(False, "wait", "required contexts not green: " + ", ".join(missing_or_bad))
|
||||
return MergeDecision(True, "merge", "ready")
|
||||
@@ -253,27 +282,32 @@ 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")
|
||||
# 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).
|
||||
combined_statuses: list[dict] = combined.get("statuses") or []
|
||||
try:
|
||||
_, all_statuses = api(
|
||||
_, all_statuses_raw = api(
|
||||
"GET",
|
||||
f"/repos/{OWNER}/{NAME}/commits/{sha}/statuses",
|
||||
query={"limit": "50"},
|
||||
)
|
||||
if isinstance(all_statuses, list):
|
||||
combined["statuses"] = all_statuses
|
||||
if isinstance(all_statuses_raw, list):
|
||||
all_statuses: list[dict] = list(all_statuses_raw)
|
||||
else:
|
||||
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")
|
||||
# Fall back to the statuses[] already in the combined response.
|
||||
pass
|
||||
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())
|
||||
return combined
|
||||
|
||||
|
||||
@@ -338,7 +372,16 @@ def merge_pull(pr_number: int, *, dry_run: bool) -> None:
|
||||
print(f"::notice::merging PR #{pr_number}")
|
||||
if dry_run:
|
||||
return
|
||||
api("POST", f"/repos/{OWNER}/{NAME}/pulls/{pr_number}/merge", body=payload, expect_json=False)
|
||||
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
|
||||
|
||||
|
||||
def process_once(*, dry_run: bool = False) -> int:
|
||||
@@ -380,11 +423,13 @@ 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}")
|
||||
@@ -407,7 +452,25 @@ def process_once(*, dry_run: bool = False) -> int:
|
||||
"deferring to next tick"
|
||||
)
|
||||
return 0
|
||||
merge_pull(pr_number, dry_run=dry_run)
|
||||
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
|
||||
return 0
|
||||
return 0
|
||||
|
||||
|
||||
@@ -118,3 +118,13 @@ 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)
|
||||
|
||||
@@ -162,6 +162,7 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
python -m twine upload \
|
||||
--verbose \
|
||||
--repository pypi \
|
||||
--username __token__ \
|
||||
--password "$PYPI_TOKEN" \
|
||||
|
||||
@@ -287,4 +287,17 @@ body {
|
||||
outline: 2px solid var(--accent, #3b5bdb);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Global focus-visible fallback for Tailwind buttons (WCAG 2.4.7).
|
||||
Specific component rules (e.g. .settings-panel__close:focus-visible)
|
||||
override this via higher specificity. */
|
||||
button:focus-visible {
|
||||
outline: 2px solid var(--accent, #3b5bdb);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
a:focus-visible {
|
||||
outline: 2px solid var(--accent, #3b5bdb);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,6 +205,7 @@ export function MobileCanvas({
|
||||
type="button"
|
||||
onClick={resetView}
|
||||
aria-label="Reset zoom"
|
||||
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 14,
|
||||
@@ -272,6 +273,8 @@ export function MobileCanvas({
|
||||
key={l.agent.id}
|
||||
type="button"
|
||||
onClick={() => onOpen(l.agent.id)}
|
||||
aria-label={`Open ${l.agent.name}`}
|
||||
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${l.x}%`,
|
||||
@@ -376,6 +379,7 @@ export function MobileCanvas({
|
||||
type="button"
|
||||
onClick={onSpawn}
|
||||
aria-label="Spawn new agent"
|
||||
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2"
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 24,
|
||||
|
||||
@@ -339,6 +339,7 @@ export function MobileChat({
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
aria-label="Back"
|
||||
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
@@ -385,6 +386,7 @@ export function MobileChat({
|
||||
<button
|
||||
type="button"
|
||||
aria-label="More"
|
||||
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
@@ -402,35 +404,62 @@ export function MobileChat({
|
||||
</button>
|
||||
</div>
|
||||
{/* Sub-tabs */}
|
||||
<div style={{ display: "flex", gap: 18, marginTop: 12, paddingLeft: 4 }}>
|
||||
{(
|
||||
[
|
||||
{ id: "my", label: "My Chat" },
|
||||
{ id: "a2a", label: "Agent Comms" },
|
||||
] as const
|
||||
).map((t) => {
|
||||
const on = tab === t.id;
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
type="button"
|
||||
onClick={() => setTab(t.id)}
|
||||
style={{
|
||||
padding: "4px 0 8px",
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
fontSize: 13.5,
|
||||
cursor: "pointer",
|
||||
color: on ? p.text : p.text3,
|
||||
fontWeight: on ? 600 : 500,
|
||||
borderBottom: on ? `2px solid ${p.accent}` : "2px solid transparent",
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{(
|
||||
[
|
||||
{ id: "my", label: "My Chat" },
|
||||
{ id: "a2a", label: "Agent Comms" },
|
||||
] as const
|
||||
).map((t) => {
|
||||
const on = tab === t.id;
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
role="tab"
|
||||
type="button"
|
||||
tabIndex={on ? 0 : -1}
|
||||
aria-selected={on}
|
||||
onClick={() => setTab(t.id)}
|
||||
onKeyDown={(e) => {
|
||||
const tabs = ["my", "a2a"] as const;
|
||||
const idx = tabs.indexOf(t.id);
|
||||
let nextIdx: number | null = null;
|
||||
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
|
||||
nextIdx = (idx + 1) % tabs.length;
|
||||
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
|
||||
nextIdx = (idx - 1 + tabs.length) % tabs.length;
|
||||
} else if (e.key === "Home") {
|
||||
nextIdx = 0;
|
||||
} else if (e.key === "End") {
|
||||
nextIdx = tabs.length - 1;
|
||||
}
|
||||
if (nextIdx !== null) {
|
||||
e.preventDefault();
|
||||
setTab(tabs[nextIdx]!);
|
||||
setTimeout(() => {
|
||||
const btns = document.querySelectorAll('[role="tab"]');
|
||||
(btns[nextIdx!] as HTMLButtonElement | null)?.focus();
|
||||
}, 0);
|
||||
}
|
||||
}}
|
||||
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
style={{
|
||||
padding: "4px 0 8px",
|
||||
marginTop: 12,
|
||||
marginLeft: 4,
|
||||
marginRight: 14,
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
fontSize: 13.5,
|
||||
cursor: "pointer",
|
||||
color: on ? p.text : p.text3,
|
||||
fontWeight: on ? 600 : 500,
|
||||
borderBottom: on ? `2px solid ${p.accent}` : "2px solid transparent",
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Messages */}
|
||||
@@ -478,6 +507,7 @@ export function MobileChat({
|
||||
onClick={() => {
|
||||
loadInitial();
|
||||
}}
|
||||
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
style={{
|
||||
padding: "6px 14px",
|
||||
borderRadius: 14,
|
||||
@@ -619,6 +649,7 @@ export function MobileChat({
|
||||
type="button"
|
||||
onClick={() => removePendingFile(i)}
|
||||
aria-label={`Remove ${f.name}`}
|
||||
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
style={{
|
||||
border: "none",
|
||||
background: "transparent",
|
||||
@@ -659,6 +690,7 @@ export function MobileChat({
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={!reachable || sending || uploading}
|
||||
aria-label="Attach"
|
||||
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
@@ -703,7 +735,9 @@ export function MobileChat({
|
||||
border: "none",
|
||||
outline: "none",
|
||||
background: "transparent",
|
||||
fontSize: 14.5,
|
||||
// 16px minimum prevents iOS from zooming the page when the
|
||||
// textarea receives focus (iOS triggers zoom for font-size < 16).
|
||||
fontSize: 16,
|
||||
lineHeight: 1.4,
|
||||
color: p.text,
|
||||
padding: "6px 0",
|
||||
@@ -719,6 +753,7 @@ export function MobileChat({
|
||||
onClick={send}
|
||||
disabled={(!draft.trim() && pendingFiles.length === 0) || !reachable || sending || uploading}
|
||||
aria-label="Send"
|
||||
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
|
||||
@@ -205,7 +205,11 @@ export function MobileComms({ dark }: { dark: boolean }) {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", gap: 6, padding: "12px 16px 8px" }}>
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Filter communications"
|
||||
style={{ display: "flex", gap: 6, padding: "12px 16px 8px" }}
|
||||
>
|
||||
{(
|
||||
[
|
||||
{ id: "all", label: "All", n: items.length },
|
||||
@@ -216,8 +220,34 @@ export function MobileComms({ dark }: { dark: boolean }) {
|
||||
return (
|
||||
<button
|
||||
key={o.id}
|
||||
role="radio"
|
||||
type="button"
|
||||
aria-checked={on}
|
||||
tabIndex={on ? 0 : -1}
|
||||
onClick={() => setFilter(o.id)}
|
||||
onKeyDown={(e) => {
|
||||
const filters = ["all", "errors"] as const;
|
||||
const idx = filters.indexOf(o.id as "all" | "errors");
|
||||
let next: number | null = null;
|
||||
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
|
||||
next = (idx + 1) % filters.length;
|
||||
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
|
||||
next = (idx - 1 + filters.length) % filters.length;
|
||||
} else if (e.key === "Home") {
|
||||
next = 0;
|
||||
} else if (e.key === "End") {
|
||||
next = filters.length - 1;
|
||||
}
|
||||
if (next !== null) {
|
||||
e.preventDefault();
|
||||
setFilter(filters[next]!);
|
||||
setTimeout(() => {
|
||||
const btns = document.querySelectorAll('[role="radio"]');
|
||||
(btns[next!] as HTMLButtonElement | null)?.focus();
|
||||
}, 0);
|
||||
}
|
||||
}}
|
||||
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
@@ -233,13 +263,7 @@ export function MobileComms({ dark }: { dark: boolean }) {
|
||||
}}
|
||||
>
|
||||
{o.label}
|
||||
<span
|
||||
style={{
|
||||
fontSize: 10.5,
|
||||
opacity: 0.7,
|
||||
fontFamily: MOBILE_FONT_MONO,
|
||||
}}
|
||||
>
|
||||
<span aria-hidden="true" style={{ fontSize: 10.5, opacity: 0.7, fontFamily: MOBILE_FONT_MONO }}>
|
||||
{o.n}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -83,11 +83,12 @@ export function MobileDetail({
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
aria-label="Back"
|
||||
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
style={iconButtonStyle(p, dark)}
|
||||
>
|
||||
{Icons.back({ size: 18 })}
|
||||
</button>
|
||||
<button type="button" aria-label="More" style={iconButtonStyle(p, dark)}>
|
||||
<button type="button" aria-label="More" className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1" style={iconButtonStyle(p, dark)}>
|
||||
{Icons.more({ size: 18 })}
|
||||
</button>
|
||||
</div>
|
||||
@@ -168,6 +169,8 @@ export function MobileDetail({
|
||||
|
||||
{/* Tabs */}
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label="Agent detail sections"
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 4,
|
||||
@@ -181,8 +184,33 @@ export function MobileDetail({
|
||||
return (
|
||||
<button
|
||||
key={t.id}
|
||||
role="tab"
|
||||
type="button"
|
||||
tabIndex={on ? 0 : -1}
|
||||
aria-selected={on}
|
||||
onClick={() => setTab(t.id)}
|
||||
onKeyDown={(e) => {
|
||||
const idx = TABS.findIndex((x) => x.id === t.id);
|
||||
let nextIdx: number | null = null;
|
||||
if (e.key === "ArrowRight" || e.key === "ArrowDown") {
|
||||
nextIdx = (idx + 1) % TABS.length;
|
||||
} else if (e.key === "ArrowLeft" || e.key === "ArrowUp") {
|
||||
nextIdx = (idx - 1 + TABS.length) % TABS.length;
|
||||
} else if (e.key === "Home") {
|
||||
nextIdx = 0;
|
||||
} else if (e.key === "End") {
|
||||
nextIdx = TABS.length - 1;
|
||||
}
|
||||
if (nextIdx !== null) {
|
||||
e.preventDefault();
|
||||
setTab(TABS[nextIdx]!.id);
|
||||
setTimeout(() => {
|
||||
const btns = document.querySelectorAll('[role="tab"]');
|
||||
(btns[nextIdx!] as HTMLButtonElement | null)?.focus();
|
||||
}, 0);
|
||||
}
|
||||
}}
|
||||
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
style={{
|
||||
padding: "8px 14px",
|
||||
borderRadius: 999,
|
||||
@@ -215,6 +243,7 @@ export function MobileDetail({
|
||||
type="button"
|
||||
onClick={onChat}
|
||||
data-testid="mobile-chat-cta"
|
||||
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: 52,
|
||||
|
||||
@@ -183,6 +183,7 @@ export function MobileHome({
|
||||
type="button"
|
||||
onClick={onSpawn}
|
||||
aria-label="Spawn new agent"
|
||||
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2"
|
||||
style={{
|
||||
position: "absolute",
|
||||
right: 24,
|
||||
|
||||
@@ -83,6 +83,7 @@ export function MobileMe({
|
||||
type="button"
|
||||
onClick={() => setAccent(c)}
|
||||
aria-label={`Set accent ${c}`}
|
||||
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
style={{
|
||||
width: 36,
|
||||
height: 36,
|
||||
@@ -173,6 +174,7 @@ function SegmentedRow({
|
||||
key={o.id}
|
||||
type="button"
|
||||
onClick={() => onChange(o.id)}
|
||||
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "10px 8px",
|
||||
|
||||
@@ -148,6 +148,7 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label="Close"
|
||||
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
style={{
|
||||
width: 32,
|
||||
height: 32,
|
||||
@@ -214,6 +215,7 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v
|
||||
setTplId(t.id);
|
||||
setTier(tCode);
|
||||
}}
|
||||
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
style={{
|
||||
background: on
|
||||
? dark
|
||||
@@ -286,7 +288,7 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{Icons.check({ size: 10, sw: 2.5 })}
|
||||
<span aria-hidden="true">{Icons.check({ size: 10, sw: 2.5 })}</span>
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
@@ -330,6 +332,7 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v
|
||||
key={t}
|
||||
type="button"
|
||||
onClick={() => setTier(t)}
|
||||
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
style={{
|
||||
flex: 1,
|
||||
padding: "10px 8px",
|
||||
@@ -377,6 +380,7 @@ export function MobileSpawn({ dark, onClose }: { dark: boolean; onClose: () => v
|
||||
type="button"
|
||||
onClick={handleSpawn}
|
||||
disabled={busy || !tplId || templates.length === 0}
|
||||
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2"
|
||||
style={{
|
||||
width: "100%",
|
||||
height: 52,
|
||||
|
||||
@@ -291,6 +291,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-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
style={{
|
||||
display: "block",
|
||||
width: "100%",
|
||||
@@ -444,6 +445,7 @@ export function FilterChips({
|
||||
type="button"
|
||||
aria-checked={on}
|
||||
onClick={() => onChange(o.id)}
|
||||
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
|
||||
style={{
|
||||
display: "inline-flex",
|
||||
alignItems: "center",
|
||||
|
||||
@@ -411,6 +411,11 @@
|
||||
color: #f4f4f5;
|
||||
}
|
||||
|
||||
.secrets-tab__add-btn:focus-visible {
|
||||
outline: var(--focus-ring);
|
||||
outline-offset: var(--focus-ring-offset);
|
||||
}
|
||||
|
||||
/* ── Shared UI ─────────────────────────────────────── */
|
||||
|
||||
.key-value-field {
|
||||
@@ -585,6 +590,11 @@
|
||||
background: #1e40af;
|
||||
}
|
||||
|
||||
.secrets-tab__refresh-btn:focus-visible {
|
||||
outline: var(--focus-ring);
|
||||
outline-offset: var(--focus-ring-offset);
|
||||
}
|
||||
|
||||
.secrets-tab__no-results {
|
||||
text-align: center;
|
||||
padding: 24px;
|
||||
@@ -601,6 +611,11 @@
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.secrets-tab__clear-search:focus-visible {
|
||||
outline: var(--focus-ring);
|
||||
outline-offset: var(--focus-ring-offset);
|
||||
}
|
||||
|
||||
/* ── Delete confirmation dialog ────────────────────── */
|
||||
|
||||
.delete-dialog__overlay {
|
||||
@@ -661,6 +676,16 @@
|
||||
|
||||
.delete-dialog__confirm-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
|
||||
.delete-dialog__confirm-btn:focus-visible {
|
||||
outline: var(--focus-ring);
|
||||
outline-offset: var(--focus-ring-offset);
|
||||
}
|
||||
|
||||
.delete-dialog__cancel-btn:focus-visible {
|
||||
outline: var(--focus-ring);
|
||||
outline-offset: var(--focus-ring-offset);
|
||||
}
|
||||
|
||||
/* ── Unsaved changes guard ─────────────────────────── */
|
||||
|
||||
.guard-dialog__overlay {
|
||||
|
||||
Reference in New Issue
Block a user