mirror of
https://github.com/openswarm-ai/openswarm.git
synced 2026-09-23 01:54:52 +02:00
Merge remote-tracking branch 'origin/aidan/feat/scheduled-tasks' into aidan/feat/scheduled-tasks-agent-tools
# Conflicts: # backend/apps/agents/agent_manager.py
This commit is contained in:
@@ -29,13 +29,13 @@ from backend.config.paths import SESSIONS_DIR
|
||||
from backend.apps.agents.core.error_classify import (
|
||||
_NON_TRANSIENT_PATTERNS,
|
||||
_TRANSIENT_CAPACITY_PATTERNS,
|
||||
_is_auth_error,
|
||||
_is_free_trial_exhausted,
|
||||
_is_long_context_error,
|
||||
_is_out_of_tokens,
|
||||
_is_transient_capacity_error,
|
||||
_is_unknown_model_error,
|
||||
_extract_reset_hint,
|
||||
p_is_auth_error,
|
||||
p_is_free_trial_exhausted,
|
||||
p_is_long_context_error,
|
||||
p_is_out_of_tokens,
|
||||
p_is_transient_capacity_error,
|
||||
p_is_unknown_model_error,
|
||||
p_extract_reset_hint,
|
||||
)
|
||||
from backend.apps.agents.manager.session.session_store import (
|
||||
_delete_session_file,
|
||||
@@ -606,7 +606,7 @@ class AgentManager:
|
||||
# Native-scheduler tools that commit or mutate a recurring schedule.
|
||||
# Always-on MCP servers fall through to the always_allow default, so
|
||||
# these would otherwise fire silently; force them through ApprovalBar.
|
||||
_SCHEDULE_GATED = {
|
||||
p_SCHEDULE_GATED = {
|
||||
"mcp__openswarm-schedule__ScheduleWorkflow",
|
||||
"mcp__openswarm-schedule__UpdateScheduledWorkflow",
|
||||
"mcp__openswarm-schedule__DeleteScheduledWorkflow",
|
||||
@@ -744,7 +744,7 @@ class AgentManager:
|
||||
# twin of the crontab gate above: real, user-visible, hard-to-undo,
|
||||
# so it goes through ApprovalBar every time regardless of the
|
||||
# always_allow default that always-on MCP servers fall through to.
|
||||
if tool_name in _SCHEDULE_GATED:
|
||||
if tool_name in p_SCHEDULE_GATED:
|
||||
return "ask", None
|
||||
if tool_name == "Bash" and isinstance(tool_input, dict):
|
||||
bash_match = _match_bash_catastrophic_pattern(str(tool_input.get("command") or ""))
|
||||
@@ -866,7 +866,7 @@ class AgentManager:
|
||||
# Record which tools each step touched (in-memory; the executor/test
|
||||
# path persists step_usage once at run end). Captures every tool the
|
||||
# gate sees so a step's tool set is complete, not only the ones that
|
||||
# prompted. No-op outside a workflow run or before a step is set.
|
||||
# prompted.
|
||||
mem = p_approval_memory.get(session_id)
|
||||
if mem is None or mem.current_step_id is None:
|
||||
return
|
||||
@@ -3087,7 +3087,7 @@ class AgentManager:
|
||||
_ticker_task = None
|
||||
stderr_snapshot = "\n".join(_stderr_buffer[-50:])
|
||||
if (
|
||||
_is_transient_capacity_error(e, extra_text=stderr_snapshot)
|
||||
p_is_transient_capacity_error(e, extra_text=stderr_snapshot)
|
||||
and capacity_retry_attempt < len(_CAPACITY_BACKOFFS)
|
||||
):
|
||||
wait = _CAPACITY_BACKOFFS[capacity_retry_attempt]
|
||||
@@ -3177,7 +3177,7 @@ class AgentManager:
|
||||
# subsequent step (title gen, follow-up tool turn, etc.).
|
||||
# Don't blast a "context exceeded" card over a completed reply.
|
||||
_streamed_substantive = bool(stream_text_msg_id) and _current_turn_emitted
|
||||
if _streamed_substantive and _is_long_context_error(e, extra_text=_stderr_tail):
|
||||
if _streamed_substantive and p_is_long_context_error(e, extra_text=_stderr_tail):
|
||||
# Mark the session completed (not error), keep the assistant
|
||||
# reply visible, and skip the overflow card. The next user
|
||||
# turn will properly hit the pre-send guard if the chat is
|
||||
@@ -3192,7 +3192,7 @@ class AgentManager:
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
if _is_long_context_error(e, extra_text=_stderr_tail):
|
||||
if p_is_long_context_error(e, extra_text=_stderr_tail):
|
||||
friendly_msg = (
|
||||
"This conversation has grown too large for your account's "
|
||||
"standard context window. Long-context requests require an "
|
||||
@@ -3236,7 +3236,7 @@ class AgentManager:
|
||||
})
|
||||
except Exception:
|
||||
logger.debug("submit_diagnostic for context_overflow failed", exc_info=True)
|
||||
elif _is_free_trial_exhausted(e, extra_text=_stderr_tail):
|
||||
elif p_is_free_trial_exhausted(e, extra_text=_stderr_tail):
|
||||
# Free runs spent. Flip back to own_key and show a friendly
|
||||
# "connect a model" upsell instead of a raw 402.
|
||||
try:
|
||||
@@ -3259,12 +3259,12 @@ class AgentManager:
|
||||
"session_id": session_id,
|
||||
"message": error_msg.model_dump(mode="json"),
|
||||
})
|
||||
elif _is_out_of_tokens(e, extra_text=_stderr_tail):
|
||||
elif p_is_out_of_tokens(e, extra_text=_stderr_tail):
|
||||
# Usage/quota spent (plan cap, API credit balance, provider quota).
|
||||
# Not a bug and not transient: tell the user plainly it's a token
|
||||
# limit and let them wait for the reset or switch models. Drives the
|
||||
# blocking out_of_tokens card (same slot as auth/context_overflow).
|
||||
_reset = _extract_reset_hint(f"{e!s}\n{_stderr_tail}")
|
||||
_reset = p_extract_reset_hint(f"{e!s}\n{_stderr_tail}")
|
||||
friendly_msg = (
|
||||
f"You're out of tokens on {session.model or 'this model'}. "
|
||||
"This isn't a bug, and nothing's wrong on your end. Your usage "
|
||||
@@ -3284,7 +3284,7 @@ class AgentManager:
|
||||
"session_id": session_id,
|
||||
"message": error_msg.model_dump(mode="json"),
|
||||
})
|
||||
elif _is_auth_error(e, extra_text=_stderr_tail):
|
||||
elif p_is_auth_error(e, extra_text=_stderr_tail):
|
||||
# Three sub-cases the user can hit, with distinct fixes:
|
||||
# 1. "No credentials for provider: claude", user picked a
|
||||
# -cc route but doesn't have Claude Pro/Max connected
|
||||
@@ -3350,7 +3350,7 @@ class AgentManager:
|
||||
"session_id": session_id,
|
||||
"message": error_msg.model_dump(mode="json"),
|
||||
})
|
||||
elif _is_unknown_model_error(e, extra_text=_stderr_tail):
|
||||
elif p_is_unknown_model_error(e, extra_text=_stderr_tail):
|
||||
# Upstream rejected the model code itself (e.g. Codex 1211 on a
|
||||
# ChatGPT plan that lacks our GPT ids). Track it; the friendly
|
||||
# "add an API key / pick another model" card is rendered frontend-side.
|
||||
@@ -3372,7 +3372,7 @@ class AgentManager:
|
||||
"session_id": session_id,
|
||||
"message": error_msg.model_dump(mode="json"),
|
||||
})
|
||||
elif _is_transient_capacity_error(e, extra_text=_stderr_tail):
|
||||
elif p_is_transient_capacity_error(e, extra_text=_stderr_tail):
|
||||
friendly_msg = (
|
||||
"provider_rate_limit: This model hit your account or "
|
||||
"session rate limit. Wait until the reset time shown by "
|
||||
|
||||
@@ -37,7 +37,7 @@ _NON_TRANSIENT_PATTERNS = re.compile(
|
||||
)
|
||||
|
||||
|
||||
def _is_long_context_error(exc: BaseException, extra_text: str = "") -> bool:
|
||||
def p_is_long_context_error(exc: BaseException, extra_text: str = "") -> bool:
|
||||
"""True when the upstream error is the 'long context tier required' 429.
|
||||
|
||||
Used by the catch-all error path to emit a friendly context-overflow
|
||||
@@ -54,7 +54,7 @@ def _is_long_context_error(exc: BaseException, extra_text: str = "") -> bool:
|
||||
))
|
||||
|
||||
|
||||
def _is_free_trial_exhausted(exc: BaseException, extra_text: str = "") -> bool:
|
||||
def p_is_free_trial_exhausted(exc: BaseException, extra_text: str = "") -> bool:
|
||||
"""True when the cloud says the machine's free runs are spent (a 402 with
|
||||
type free_trial_exhausted). The catch-all path uses this to flip back to
|
||||
own_key and show a friendly connect-a-model upsell instead of a raw error.
|
||||
@@ -69,13 +69,7 @@ def _is_free_trial_exhausted(exc: BaseException, extra_text: str = "") -> bool:
|
||||
))
|
||||
|
||||
|
||||
def _is_out_of_tokens(exc: BaseException, extra_text: str = "") -> bool:
|
||||
"""True when the turn was rejected because the user's usage/quota is spent: a
|
||||
plan cap, API credit balance, or provider quota. This is a 'wait for the reset
|
||||
window or switch models' situation, NOT a transient rate-limit blip (handled by
|
||||
_is_transient_capacity_error) and NOT an auth/connection failure. Drives the
|
||||
friendly 'just a token issue' card.
|
||||
"""
|
||||
def p_is_out_of_tokens(exc: BaseException, extra_text: str = "") -> bool:
|
||||
combined = f"{exc!s}\n{extra_text}".strip()
|
||||
if not combined:
|
||||
return False
|
||||
@@ -93,11 +87,9 @@ def _is_out_of_tokens(exc: BaseException, extra_text: str = "") -> bool:
|
||||
))
|
||||
|
||||
|
||||
def _extract_reset_hint(text: str) -> str:
|
||||
def p_extract_reset_hint(text: str) -> str:
|
||||
"""Pull a human reset phrase ('at 7:42 AM', 'in 2h 30m', 'after 1m 59s') out of
|
||||
a provider usage error so we can tell the user when their limit comes back.
|
||||
Keeps the leading preposition so it slots into 'It resets <hint>.'. Empty when
|
||||
the provider didn't say.
|
||||
"""
|
||||
if not text:
|
||||
return ""
|
||||
@@ -109,13 +101,10 @@ def _extract_reset_hint(text: str) -> str:
|
||||
return m.group(1).strip() if m else ""
|
||||
|
||||
|
||||
def _is_auth_error(exc: BaseException, extra_text: str = "") -> bool:
|
||||
def p_is_auth_error(exc: BaseException, extra_text: str = "") -> bool:
|
||||
"""True when the upstream error is a 401/403 auth failure.
|
||||
|
||||
Used by the catch-all error path to surface a friendly "subscription
|
||||
expired / reconnect" card instead of dumping the raw 401 JSON. The most
|
||||
common cause: the OpenSwarm Pro bearer or 9Router OAuth token has expired
|
||||
while the UI still shows the connection as 'connected'.
|
||||
expired / reconnect" card instead of dumping the raw 401 JSON.
|
||||
"""
|
||||
combined = f"{exc!s}\n{extra_text}".strip()
|
||||
if not combined:
|
||||
@@ -133,7 +122,7 @@ def _is_auth_error(exc: BaseException, extra_text: str = "") -> bool:
|
||||
))
|
||||
|
||||
|
||||
def _is_unknown_model_error(exc: BaseException, extra_text: str = "") -> bool:
|
||||
def p_is_unknown_model_error(exc: BaseException, extra_text: str = "") -> bool:
|
||||
"""True when the upstream rejects the model code itself (e.g. a ChatGPT/Codex
|
||||
subscription whose plan doesn't expose the GPT model id we send: code 1211
|
||||
'Unknown Model, please check the model code'). The fix isn't retry, it's a
|
||||
@@ -153,7 +142,7 @@ def _is_unknown_model_error(exc: BaseException, extra_text: str = "") -> bool:
|
||||
))
|
||||
|
||||
|
||||
def _is_transient_capacity_error(exc: BaseException, extra_text: str = "") -> bool:
|
||||
def p_is_transient_capacity_error(exc: BaseException, extra_text: str = "") -> bool:
|
||||
# The Claude CLI's underlying ProcessError stringifies to a generic
|
||||
# "Command failed with exit code 1 / Check stderr output for details";
|
||||
# the real cause (rate_limit_error / No pool capacity available / 429
|
||||
|
||||
@@ -24,7 +24,7 @@ PARENT_SESSION_ID = os.environ.get("OPENSWARM_PARENT_SESSION_ID", "")
|
||||
DASHBOARD_ID = os.environ.get("OPENSWARM_DASHBOARD_ID", "")
|
||||
|
||||
|
||||
def _local_timezone_name() -> str:
|
||||
def p_local_timezone_name() -> str:
|
||||
name = os.environ.get("OPENSWARM_TIMEZONE", "").strip()
|
||||
if not name:
|
||||
try:
|
||||
@@ -311,8 +311,8 @@ def _call(method: str, path: str, body=None) -> dict:
|
||||
|
||||
|
||||
def _build_schedule_from_preset(preset: str, args: dict) -> dict:
|
||||
local_tz = _local_timezone_name()
|
||||
base = {"timezone": args.get("timezone") or local_tz, "on_missed": "skip", "ends_at": None, "max_runs": None, "runs_count": 0}
|
||||
local_tz = p_local_timezone_name()
|
||||
base = {"timezone": args.get("timezone") or local_tz, "ends_at": None, "max_runs": None, "runs_count": 0}
|
||||
if preset == "custom":
|
||||
return {
|
||||
**base,
|
||||
|
||||
@@ -5,8 +5,8 @@ import backend # noqa: F401 (path sanity asserted below)
|
||||
from backend.apps.settings.models import AppSettings
|
||||
from backend.apps.settings.credentials import proxy_auth
|
||||
from backend.apps.agents.core.error_classify import (
|
||||
_is_free_trial_exhausted,
|
||||
_is_transient_capacity_error,
|
||||
p_is_free_trial_exhausted,
|
||||
p_is_transient_capacity_error,
|
||||
)
|
||||
from backend.apps.agents.providers.registry import resolve_model_id_for_sdk
|
||||
from backend.apps.subscription.free_trial import _has_own_model
|
||||
@@ -41,11 +41,11 @@ def test_free_trial_resolves_to_a_bare_anthropic_id():
|
||||
|
||||
|
||||
def test_exhaustion_is_classified_and_not_retried():
|
||||
assert _is_free_trial_exhausted(Exception("error type free_trial_exhausted"))
|
||||
assert _is_free_trial_exhausted(Exception("You've used your free OpenSwarm runs"))
|
||||
assert not _is_free_trial_exhausted(Exception("overloaded, try again"))
|
||||
assert p_is_free_trial_exhausted(Exception("error type free_trial_exhausted"))
|
||||
assert p_is_free_trial_exhausted(Exception("You've used your free OpenSwarm runs"))
|
||||
assert not p_is_free_trial_exhausted(Exception("overloaded, try again"))
|
||||
# Must NOT look transient, or the agent loop would retry a spent trial forever.
|
||||
assert not _is_transient_capacity_error(Exception("free_trial_exhausted"))
|
||||
assert not p_is_transient_capacity_error(Exception("free_trial_exhausted"))
|
||||
|
||||
|
||||
def test_generic_cli_failure_uses_sdk_system_events_for_rate_limits():
|
||||
@@ -53,7 +53,7 @@ def test_generic_cli_failure_uses_sdk_system_events_for_rate_limits():
|
||||
'{"subtype":"api_retry","data":{"error_status":429,'
|
||||
'"error":"rate_limit","max_retries":10}}'
|
||||
)
|
||||
assert _is_transient_capacity_error(
|
||||
assert p_is_transient_capacity_error(
|
||||
Exception("Command failed with exit code 1"),
|
||||
extra_text=system_event_tail,
|
||||
)
|
||||
|
||||
@@ -592,8 +592,8 @@ def test_router_auth_pattern_does_not_falsely_match_normal_text():
|
||||
|
||||
|
||||
def test_is_auth_error_classifier():
|
||||
"""The classifier at agent_manager.py:_is_auth_error covers many shapes."""
|
||||
from backend.apps.agents.agent_manager import _is_auth_error
|
||||
"""The classifier at agent_manager.py:p_is_auth_error covers many shapes."""
|
||||
from backend.apps.agents.agent_manager import p_is_auth_error
|
||||
|
||||
# Real shapes that must be caught
|
||||
matches = [
|
||||
@@ -606,7 +606,7 @@ def test_is_auth_error_classifier():
|
||||
Exception("Provider not configured: gemini"),
|
||||
]
|
||||
for e in matches:
|
||||
assert _is_auth_error(e), f"should match: {e}"
|
||||
assert p_is_auth_error(e), f"should match: {e}"
|
||||
|
||||
# Non-auth errors must not match
|
||||
non_matches = [
|
||||
@@ -616,15 +616,15 @@ def test_is_auth_error_classifier():
|
||||
Exception("File not found"),
|
||||
]
|
||||
for e in non_matches:
|
||||
assert not _is_auth_error(e), f"should NOT match: {e}"
|
||||
assert not p_is_auth_error(e), f"should NOT match: {e}"
|
||||
|
||||
|
||||
def test_is_auth_error_with_stderr_tail():
|
||||
"""The classifier also reads stderr buffer text."""
|
||||
from backend.apps.agents.agent_manager import _is_auth_error
|
||||
from backend.apps.agents.agent_manager import p_is_auth_error
|
||||
e = Exception("Command failed with exit code 1")
|
||||
stderr = "...\n[codex/gpt-5.5] [401]: Provided authentication token is expired"
|
||||
assert _is_auth_error(e, extra_text=stderr)
|
||||
assert p_is_auth_error(e, extra_text=stderr)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
|
||||
@@ -35,6 +35,13 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD
|
||||
const dispatch = useAppDispatch();
|
||||
const workflows = useAppSelector((s) => Object.values(s.workflows.items));
|
||||
const allPaused = useAppSelector((s) => s.workflows.paused);
|
||||
// Live clock for the "now" line; a snapshot would drift and refDate may be
|
||||
// a navigated week, so it can't double as the current moment.
|
||||
const [now, setNow] = useState<Date>(() => new Date());
|
||||
useEffect(() => {
|
||||
const id = setInterval(() => setNow(new Date()), 60_000);
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
// Right-click menu: pinned position + the workflow whose pill was
|
||||
// clicked. Same anchor pattern as MUI's menu examples.
|
||||
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number; workflow: Workflow } | null>(null);
|
||||
@@ -163,6 +170,8 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD
|
||||
const start = startOfWeek(today);
|
||||
const days = Array.from({ length: 7 }, (_, i) => addDays(start, i));
|
||||
const HOURS = HOURS_24;
|
||||
const nowColIdx = days.findIndex((d) => sameDay(d, now));
|
||||
const nowTopPx = (now.getHours() + now.getMinutes() / 60) * SLOT_H;
|
||||
// Prefer the short zone name ("PDT", "EST", "JST") so the label
|
||||
// reads in plain English instead of "GMT-7". formatToParts is wide-
|
||||
// supported; if it ever fails we degrade silently rather than show
|
||||
@@ -194,7 +203,7 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '64px repeat(7, 1fr)', borderTop: `1px solid ${c.border.subtle}` }}>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: '64px repeat(7, 1fr)', borderTop: `1px solid ${c.border.subtle}`, position: 'relative' }}>
|
||||
{HOURS.map((hour, hourIdx) => (
|
||||
<React.Fragment key={hour}>
|
||||
{/* Hour label sits inside its row (top-aligned) rather than
|
||||
@@ -247,6 +256,17 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD
|
||||
})}
|
||||
</React.Fragment>
|
||||
))}
|
||||
{nowColIdx >= 0 && (
|
||||
<Box sx={{
|
||||
position: 'absolute', pointerEvents: 'none', zIndex: 3,
|
||||
top: `${nowTopPx}px`,
|
||||
left: `calc(64px + ${nowColIdx} * ((100% - 64px) / 7))`,
|
||||
width: 'calc((100% - 64px) / 7)',
|
||||
height: 0, borderTop: `2px solid ${c.status.error}`,
|
||||
}}>
|
||||
<Box sx={{ position: 'absolute', left: -3, top: -4, width: 7, height: 7, borderRadius: '50%', bgcolor: c.status.error }} />
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{ctxMenuEl}
|
||||
</Box>
|
||||
@@ -264,7 +284,7 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD
|
||||
reads cleanly in both light and dark themes. */}
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', position: 'sticky', top: 0, bgcolor: c.bg.surface, zIndex: 2, borderBottom: `1px solid ${c.border.subtle}`, py: 0.6 }}>
|
||||
{WEEKDAY_LABEL_SHORT.map((l, i) => (
|
||||
<Typography key={`${l}-${i}`} sx={{ textAlign: 'center', fontSize: '0.74rem', color: c.text.secondary, fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase' }}>{l}</Typography>
|
||||
<Typography key={`${l}-${i}`} sx={{ textAlign: 'center', fontSize: '0.74rem', color: c.text.muted, fontWeight: 600, letterSpacing: '0.08em', textTransform: 'uppercase' }}>{l}</Typography>
|
||||
))}
|
||||
</Box>
|
||||
<Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(7, 1fr)', gap: 0, borderLeft: `1px solid ${c.border.subtle}` }}>
|
||||
@@ -357,7 +377,7 @@ export default function ScheduleCalendar({ view, density, onSelectWorkflow, refD
|
||||
fontSize: '0.88rem', color: c.text.secondary, cursor: 'pointer',
|
||||
'&:hover .ev-title': { color: accent },
|
||||
}}>
|
||||
<Box sx={{ width: 3, alignSelf: 'stretch', minHeight: 22, bgcolor: accent, borderRadius: 1, flexShrink: 0 }} />
|
||||
<Box sx={{ width: 3, alignSelf: 'stretch', minHeight: 22, bgcolor: accent, borderRadius: c.radius.sm, flexShrink: 0 }} />
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography className="ev-title" sx={{ fontSize: '0.9rem', fontWeight: 500, color: c.text.primary, lineHeight: 1.3 }}>{e.workflow.title}</Typography>
|
||||
<Typography sx={{ fontSize: '0.78rem', color: c.text.muted, lineHeight: 1.3 }}>{formatTime(e.date.getHours(), e.date.getMinutes())}</Typography>
|
||||
@@ -412,7 +432,7 @@ function EventStack({ events, paused, onSelectWorkflow, eventFontSize, onContext
|
||||
bgcolor: accent + '14',
|
||||
color: c.text.primary,
|
||||
borderLeft: `3px solid ${accent}`,
|
||||
borderRadius: '4px',
|
||||
borderRadius: c.radius.sm,
|
||||
px: 0.65, py: 0,
|
||||
fontSize: eventFontSize, fontWeight: 500,
|
||||
overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis',
|
||||
@@ -435,7 +455,7 @@ function EventStack({ events, paused, onSelectWorkflow, eventFontSize, onContext
|
||||
minWidth: 20, px: 0.4,
|
||||
bgcolor: accent + '22',
|
||||
color: accent,
|
||||
borderRadius: '4px',
|
||||
borderRadius: c.radius.sm,
|
||||
fontSize: eventFontSize, fontWeight: 700,
|
||||
cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
'&:hover': { bgcolor: accent + '33' },
|
||||
|
||||
Reference in New Issue
Block a user