diff --git a/agent/dashboard/agent_instructions.py b/agent/dashboard/agent_instructions.py new file mode 100644 index 00000000..6bd23652 --- /dev/null +++ b/agent/dashboard/agent_instructions.py @@ -0,0 +1,113 @@ +"""Per-repository custom instructions for the main coding agent. + +Each record holds a user-authored instruction prompt (edited in the dashboard) +that is appended to the main agent's system prompt for runs targeting that repo. +""" + +from __future__ import annotations + +import logging +from datetime import UTC, datetime +from typing import Any + +from langgraph_sdk import get_client +from pydantic import BaseModel, Field, field_validator + +from .review_styles import normalize_repo_full_name + +logger = logging.getLogger(__name__) + +AGENT_INSTRUCTIONS_NAMESPACE: list[str] = ["agent_instructions"] + + +class AgentInstructionsCreate(BaseModel): + full_name: str = Field(..., description="GitHub repo in owner/name form") + + @field_validator("full_name", mode="before") + @classmethod + def _valid_full_name(cls, v: str) -> str: + return normalize_repo_full_name(v) + + +class AgentInstructionsUpdate(BaseModel): + instructions: str = Field(default="") + + +def _client(): + return get_client() + + +async def _get_value(key: str) -> dict[str, Any] | None: + try: + item = await _client().store.get_item(AGENT_INSTRUCTIONS_NAMESPACE, key) + except Exception as e: # noqa: BLE001 + logger.debug("store get_item failed for %s: %s", key, e) + return None + if item is None: + return None + value = item.get("value") if isinstance(item, dict) else getattr(item, "value", None) + return value if isinstance(value, dict) else None + + +def _now_iso() -> str: + return datetime.now(UTC).isoformat() + + +def _default_record(full_name: str, created_by: str) -> dict[str, Any]: + owner, name = full_name.split("/", 1) + return { + "full_name": full_name, + "owner": owner, + "name": name, + "instructions": "", + "created_by": created_by, + "created_at": _now_iso(), + "updated_at": _now_iso(), + } + + +async def get_agent_instructions(full_name: str) -> dict[str, Any] | None: + return await _get_value(full_name) + + +async def list_agent_instructions() -> list[dict[str, Any]]: + result = await _client().store.search_items(AGENT_INSTRUCTIONS_NAMESPACE, limit=1000) + items = result.get("items") if isinstance(result, dict) else getattr(result, "items", []) + out: list[dict[str, Any]] = [] + for item in items or []: + value = item.get("value") if isinstance(item, dict) else getattr(item, "value", None) + if isinstance(value, dict): + out.append(value) + out.sort(key=lambda r: r.get("full_name", "")) + return out + + +async def create_agent_instructions(full_name: str, created_by: str) -> dict[str, Any]: + existing = await get_agent_instructions(full_name) + if existing: + return existing + value = _default_record(full_name, created_by) + await _client().store.put_item(AGENT_INSTRUCTIONS_NAMESPACE, full_name, value) + return value + + +async def set_agent_instructions(full_name: str, instructions: str) -> dict[str, Any]: + existing = await get_agent_instructions(full_name) or _default_record(full_name, "") + value = {**existing, "instructions": instructions, "updated_at": _now_iso()} + await _client().store.put_item(AGENT_INSTRUCTIONS_NAMESPACE, full_name, value) + return value + + +async def delete_agent_instructions(full_name: str) -> None: + await _client().store.delete_item(AGENT_INSTRUCTIONS_NAMESPACE, full_name) + + +async def get_repo_agent_instructions(owner: str, repo: str) -> str | None: + """Return the custom agent instructions for a repo, if configured.""" + record = await get_agent_instructions(f"{owner}/{repo}") + if not record: + return None + instructions = record.get("instructions") + if isinstance(instructions, str) and instructions.strip(): + return instructions.strip() + return None diff --git a/agent/dashboard/routes.py b/agent/dashboard/routes.py index f832a71d..08e2cfe2 100644 --- a/agent/dashboard/routes.py +++ b/agent/dashboard/routes.py @@ -13,6 +13,15 @@ from fastapi.responses import RedirectResponse, Response, StreamingResponse from pydantic import BaseModel from .admin import is_admin +from .agent_instructions import ( + AgentInstructionsCreate, + AgentInstructionsUpdate, + create_agent_instructions, + delete_agent_instructions, + get_agent_instructions, + list_agent_instructions, + set_agent_instructions, +) from .agent_usage import ( list_agent_usage_leaderboard, refresh_reviewer_stats_cache, @@ -127,6 +136,25 @@ def _admin_session(session: dict[str, Any] = _SESSION_DEP) -> dict[str, Any]: _ADMIN_DEP = Depends(_admin_session) +async def _filter_repo_records_for_user( + login: str, + records: list[dict[str, Any]], +) -> list[dict[str, Any]]: + out: list[dict[str, Any]] = [] + for record in records: + full_name = record.get("full_name") + if not isinstance(full_name, str): + continue + try: + await require_repo_access_for_user(login, full_name) + except HTTPException as exc: + if exc.status_code in {403, 404}: + continue + raise + out.append(record) + return out + + def _api_base_url() -> str: v = os.environ.get("DASHBOARD_API_BASE_URL", "").rstrip("/") if not v: @@ -609,7 +637,7 @@ async def list_repos( async def api_list_review_styles( session: dict[str, Any] = _SESSION_DEP, ) -> list[dict[str, Any]]: - records = await list_review_styles() + records = await _filter_repo_records_for_user(session["sub"], await list_review_styles()) out: list[dict[str, Any]] = [] for record in records: if record.get("status") == "running": @@ -635,6 +663,7 @@ async def api_get_review_style( session: dict[str, Any] = _SESSION_DEP, ) -> dict[str, Any]: full_name = normalize_repo_full_name(full_name) + await require_repo_access_for_user(session["sub"], full_name) record = await get_review_style(full_name) if not record: raise HTTPException(404, "review style not found") @@ -650,10 +679,10 @@ async def api_update_review_style_prompt( session: dict[str, Any] = _SESSION_DEP, ) -> dict[str, Any]: full_name = normalize_repo_full_name(full_name) + await require_repo_access_for_user(session["sub"], full_name) record = await get_review_style(full_name) if not record: raise HTTPException(404, "review style not found") - await require_repo_access_for_user(session["sub"], full_name) return await set_custom_prompt(full_name, body.custom_prompt) @@ -683,8 +712,8 @@ async def api_cancel_review_style( full_name: str, session: dict[str, Any] = _SESSION_DEP, ) -> dict[str, Any]: - del session full_name = normalize_repo_full_name(full_name) + await require_repo_access_for_user(session["sub"], full_name) record = await get_review_style(full_name) if not record: raise HTTPException(404, "review style not found") @@ -696,8 +725,8 @@ async def api_delete_review_style( full_name: str, session: dict[str, Any] = _SESSION_DEP, ) -> Response: - del session full_name = normalize_repo_full_name(full_name) + await require_repo_access_for_user(session["sub"], full_name) record = await get_review_style(full_name) if not record: raise HTTPException(404, "review style not found") @@ -708,6 +737,60 @@ async def api_delete_review_style( return Response(status_code=204) +@router.get("/agent-instructions") +async def api_list_agent_instructions( + session: dict[str, Any] = _SESSION_DEP, +) -> list[dict[str, Any]]: + return await _filter_repo_records_for_user(session["sub"], await list_agent_instructions()) + + +@router.post("/agent-instructions") +async def api_create_agent_instructions( + body: AgentInstructionsCreate, + session: dict[str, Any] = _SESSION_DEP, +) -> dict[str, Any]: + await require_repo_access_for_user(session["sub"], body.full_name) + return await create_agent_instructions(body.full_name, session["sub"]) + + +@router.get("/agent-instructions/{full_name:path}") +async def api_get_agent_instructions( + full_name: str, + session: dict[str, Any] = _SESSION_DEP, +) -> dict[str, Any]: + full_name = normalize_repo_full_name(full_name) + await require_repo_access_for_user(session["sub"], full_name) + record = await get_agent_instructions(full_name) + if not record: + raise HTTPException(404, "agent instructions not found") + return record + + +@router.put("/agent-instructions/{full_name:path}") +async def api_update_agent_instructions( + full_name: str, + body: AgentInstructionsUpdate, + session: dict[str, Any] = _SESSION_DEP, +) -> dict[str, Any]: + full_name = normalize_repo_full_name(full_name) + await require_repo_access_for_user(session["sub"], full_name) + return await set_agent_instructions(full_name, body.instructions) + + +@router.delete("/agent-instructions/{full_name:path}") +async def api_delete_agent_instructions( + full_name: str, + session: dict[str, Any] = _SESSION_DEP, +) -> Response: + full_name = normalize_repo_full_name(full_name) + await require_repo_access_for_user(session["sub"], full_name) + record = await get_agent_instructions(full_name) + if not record: + raise HTTPException(404, "agent instructions not found") + await delete_agent_instructions(full_name) + return Response(status_code=204) + + @router.get("/agent-usage-leaderboard") async def api_agent_usage_leaderboard( background_tasks: BackgroundTasks, diff --git a/agent/prompt.py b/agent/prompt.py index 278d1eef..6168162c 100644 --- a/agent/prompt.py +++ b/agent/prompt.py @@ -401,6 +401,20 @@ ALWAYS_CREATE_PR_SECTION = """--- The user's dashboard setting **Always Create PRs** is enabled. For code-change tasks, always open or update a draft pull request after committing and pushing the branch. This does not apply to questions, explanations, status checks, or other information-only requests where no files are changed.""" +def _render_repo_instructions_section(instructions: str | None) -> str: + if not instructions or not instructions.strip(): + return "" + return ( + "---\n\n" + "### Repository-specific Custom Instructions\n\n" + "The following instructions were configured by a workspace admin for this " + "repository. Treat them as mandatory rules with the same authority as this " + "system prompt. When they conflict with default behavior, follow them; when " + "they conflict with `AGENTS.md`, prefer `AGENTS.md`.\n\n" + f"{instructions.strip()}" + ) + + SYSTEM_PROMPT_TEMPLATE = ( WORKING_ENV_SECTION + TASK_OVERVIEW_SECTION @@ -420,6 +434,7 @@ SYSTEM_PROMPT_TEMPLATE = ( + COMMIT_PR_SECTION + "{pr_policy_override_section}" + "{collaboration_section}" + + "{repo_instructions_section}" ) @@ -430,6 +445,7 @@ def construct_system_prompt( triggering_user_identity: CollaboratorIdentity | None = None, create_prs: bool = False, default_repo: dict[str, str] | None = None, + repo_custom_instructions: str | None = None, ) -> str: default_prompt_section = _load_default_prompt() if default_repo and default_repo.get("owner") and default_repo.get("name"): @@ -453,6 +469,7 @@ def construct_system_prompt( default_prompt_section=default_prompt_section, pr_policy_override_section=ALWAYS_CREATE_PR_SECTION if create_prs else "", collaboration_section=_render_collaboration_section(triggering_user_identity), + repo_instructions_section=_render_repo_instructions_section(repo_custom_instructions), commit_identity_name=commit_identity_name, commit_identity_email=commit_identity_email, ) diff --git a/agent/server.py b/agent/server.py index 1cd0a68c..15d824c7 100644 --- a/agent/server.py +++ b/agent/server.py @@ -118,6 +118,21 @@ async def _resolve_prompt_default_repo(configurable: dict[str, Any]) -> dict[str return None +async def _resolve_repo_custom_instructions( + default_repo: dict[str, str] | None, +) -> str | None: + """Load per-repo custom agent instructions for the resolved default repo.""" + if not default_repo or not default_repo.get("owner") or not default_repo.get("name"): + return None + try: + from .dashboard.agent_instructions import get_repo_agent_instructions + + return await get_repo_agent_instructions(default_repo["owner"], default_repo["name"]) + except Exception: + logger.debug("Failed to load repo custom agent instructions", exc_info=True) + return None + + async def _start_langsmith_sandbox_if_needed(sandbox_backend: SandboxBackendProtocol) -> None: """Start a LangSmith sandbox before operations that require it to be running.""" if os.getenv("SANDBOX_TYPE", "langsmith") != "langsmith": @@ -560,6 +575,7 @@ async def get_agent(config: RunnableConfig) -> Pregel: logger.debug("Failed to record agent usage for thread %s", thread_id, exc_info=True) prompt_default_repo = await _resolve_prompt_default_repo(configurable) + repo_custom_instructions = await _resolve_repo_custom_instructions(prompt_default_repo) logger.info("Returning agent with sandbox for thread %s", thread_id) main_model = make_model(model_id, **model_kwargs) @@ -573,6 +589,7 @@ async def get_agent(config: RunnableConfig) -> Pregel: triggering_user_identity=triggering_user_identity, create_prs=always_create_prs, default_repo=prompt_default_repo, + repo_custom_instructions=repo_custom_instructions, ), tools=[ http_request, diff --git a/tests/test_agent_instructions.py b/tests/test_agent_instructions.py new file mode 100644 index 00000000..8e4d86ac --- /dev/null +++ b/tests/test_agent_instructions.py @@ -0,0 +1,155 @@ +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi import HTTPException + +from agent import server +from agent.dashboard import routes +from agent.dashboard.agent_instructions import ( + create_agent_instructions, + get_repo_agent_instructions, + set_agent_instructions, +) +from agent.prompt import construct_system_prompt + + +@pytest.mark.asyncio +async def test_get_repo_agent_instructions_returns_trimmed_text() -> None: + with patch( + "agent.dashboard.agent_instructions.get_agent_instructions", + new_callable=AsyncMock, + return_value={"instructions": " Always run mypy.\n"}, + ): + result = await get_repo_agent_instructions("acme", "repo") + assert result == "Always run mypy." + + +@pytest.mark.asyncio +async def test_get_repo_agent_instructions_returns_none_when_empty() -> None: + with patch( + "agent.dashboard.agent_instructions.get_agent_instructions", + new_callable=AsyncMock, + return_value={"instructions": " "}, + ): + result = await get_repo_agent_instructions("acme", "repo") + assert result is None + + +@pytest.mark.asyncio +async def test_create_agent_instructions_puts_new_record() -> None: + mock_put = AsyncMock() + with ( + patch( + "agent.dashboard.agent_instructions._get_value", + new_callable=AsyncMock, + return_value=None, + ), + patch("agent.dashboard.agent_instructions._client") as mock_client, + ): + mock_client.return_value.store.put_item = mock_put + record = await create_agent_instructions("acme/repo", "octo") + assert record["full_name"] == "acme/repo" + assert record["instructions"] == "" + mock_put.assert_awaited_once() + + +@pytest.mark.asyncio +async def test_set_agent_instructions_updates_store() -> None: + mock_put = AsyncMock() + with ( + patch( + "agent.dashboard.agent_instructions.get_agent_instructions", + new_callable=AsyncMock, + return_value={"full_name": "acme/repo", "instructions": ""}, + ), + patch("agent.dashboard.agent_instructions._client") as mock_client, + ): + mock_client.return_value.store.put_item = mock_put + record = await set_agent_instructions("acme/repo", "Use direct tone.") + assert record["instructions"] == "Use direct tone." + mock_put.assert_awaited_once() + + +def test_construct_system_prompt_appends_repo_instructions() -> None: + prompt = construct_system_prompt( + working_dir="/work", + repo_custom_instructions="Prefer pytest over unittest.", + ) + assert "Repository-specific Custom Instructions" in prompt + assert "Prefer pytest over unittest." in prompt + + +def test_construct_system_prompt_without_repo_instructions() -> None: + prompt = construct_system_prompt(working_dir="/work") + assert "Repository-specific Custom Instructions" not in prompt + + +def test_resolve_repo_custom_instructions_returns_none_without_repo() -> None: + result = asyncio.run(server._resolve_repo_custom_instructions(None)) + assert result is None + + +@pytest.mark.asyncio +async def test_list_agent_instructions_filters_inaccessible_repos(monkeypatch) -> None: + monkeypatch.setattr( + routes, + "list_agent_instructions", + AsyncMock( + return_value=[ + {"full_name": "acme/visible", "instructions": "visible"}, + {"full_name": "acme/private", "instructions": "private"}, + ] + ), + ) + + async def fake_require_repo_access_for_user(login: str, full_name: str) -> str: + if full_name == "acme/private": + raise HTTPException(403, "no access") + return "token" + + monkeypatch.setattr(routes, "require_repo_access_for_user", fake_require_repo_access_for_user) + + result = await routes.api_list_agent_instructions(session={"sub": "octocat"}) + + assert result == [{"full_name": "acme/visible", "instructions": "visible"}] + + +@pytest.mark.asyncio +async def test_get_agent_instructions_requires_repo_access(monkeypatch) -> None: + require_access = AsyncMock(return_value="token") + monkeypatch.setattr( + routes, + "get_agent_instructions", + AsyncMock(return_value={"full_name": "acme/repo", "instructions": "rules"}), + ) + monkeypatch.setattr(routes, "require_repo_access_for_user", require_access) + + result = await routes.api_get_agent_instructions( + "https://github.com/acme/repo", session={"sub": "octocat"} + ) + + assert result == {"full_name": "acme/repo", "instructions": "rules"} + require_access.assert_awaited_once_with("octocat", "acme/repo") + + +@pytest.mark.asyncio +async def test_delete_agent_instructions_requires_repo_access_before_delete(monkeypatch) -> None: + delete_instructions = AsyncMock() + get_instructions = AsyncMock(return_value={"full_name": "acme/repo", "instructions": "rules"}) + monkeypatch.setattr(routes, "get_agent_instructions", get_instructions) + monkeypatch.setattr( + routes, + "require_repo_access_for_user", + AsyncMock(side_effect=HTTPException(403, "no access")), + ) + monkeypatch.setattr(routes, "delete_agent_instructions", delete_instructions) + + with pytest.raises(HTTPException) as exc: + await routes.api_delete_agent_instructions("acme/repo", session={"sub": "octocat"}) + + assert exc.value.status_code == 403 + get_instructions.assert_not_awaited() + delete_instructions.assert_not_awaited() diff --git a/ui/package.json b/ui/package.json index e4474299..89a85374 100644 --- a/ui/package.json +++ b/ui/package.json @@ -14,6 +14,7 @@ "dependencies": { "@base-ui/react": "^1.4.1", "@fontsource-variable/inter": "^5.2.8", + "@monaco-editor/react": "^4.7.0", "@phosphor-icons/react": "^2.1.10", "@pierre/diffs": "^1.2.1", "@tailwindcss/vite": "^4.2.1", @@ -29,6 +30,7 @@ "clsx": "^2.1.1", "diff": "^9.0.0", "lucide-react": "^1.16.0", + "monaco-editor": "^0.52.2", "nitro": "latest", "react": "^19.2.4", "react-dom": "^19.2.4", diff --git a/ui/src/components/AgentInstructionsPanel.tsx b/ui/src/components/AgentInstructionsPanel.tsx new file mode 100644 index 00000000..ac9d4f28 --- /dev/null +++ b/ui/src/components/AgentInstructionsPanel.tsx @@ -0,0 +1,272 @@ +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { useEffect, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, +} from "@/components/ui/combobox"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Skeleton } from "@/components/ui/skeleton"; +import { InstructionsEditor } from "@/components/InstructionsEditor"; +import { ApiError, api, isGithubReauthError, loginUrl } from "@/lib/api"; +import { normalizeRepoFullName } from "@/lib/repo"; + +function formatMutationError(e: Error): string { + return isGithubReauthError(e) + ? "GitHub token expired — sign in again using the link above." + : e.message; +} + +export function AgentInstructionsPanel() { + const qc = useQueryClient(); + const [error, setError] = useState(null); + const [addRepo, setAddRepo] = useState(""); + const [selected, setSelected] = useState(null); + const [draft, setDraft] = useState(""); + + const instructions = useQuery({ + queryKey: ["agentInstructions"], + queryFn: api.listAgentInstructions, + }); + + const repos = useQuery({ + queryKey: ["repos"], + queryFn: async () => { + try { + return await api.repos(); + } catch (e) { + if (e instanceof ApiError && e.status === 401) + return { installations: [], repositories: [] }; + throw e; + } + }, + }); + + const detail = useQuery({ + queryKey: ["agentInstruction", selected], + queryFn: () => api.getAgentInstructions(selected!), + enabled: !!selected, + }); + + useEffect(() => { + if (detail.data) setDraft(detail.data.instructions ?? ""); + }, [detail.data?.instructions, detail.data?.full_name]); + + const create = useMutation({ + mutationFn: (full_name: string) => api.createAgentInstructions(full_name), + onSuccess: (record) => { + void qc.invalidateQueries({ queryKey: ["agentInstructions"] }); + setSelected(record.full_name); + setError(null); + }, + onError: (e: Error) => setError(formatMutationError(e)), + }); + + const save = useMutation({ + mutationFn: ({ full_name, value }: { full_name: string; value: string }) => + api.saveAgentInstructions(full_name, value), + onSuccess: () => { + void qc.invalidateQueries({ queryKey: ["agentInstructions"] }); + void qc.invalidateQueries({ queryKey: ["agentInstruction", selected] }); + setError(null); + }, + onError: (e: Error) => setError(formatMutationError(e)), + }); + + const remove = useMutation({ + mutationFn: (full_name: string) => api.deleteAgentInstructions(full_name), + onSuccess: (_data, full_name) => { + void qc.invalidateQueries({ queryKey: ["agentInstructions"] }); + if (selected === full_name) { + setSelected(null); + setDraft(""); + } + setError(null); + }, + onError: (e: Error) => setError(formatMutationError(e)), + }); + + if (instructions.isLoading) { + return ; + } + + const configured = new Set((instructions.data ?? []).map((s) => s.full_name)); + const suggestedRepos = (repos.data?.repositories ?? []).filter( + (r) => !configured.has(r.full_name), + ); + const normalizedAddRepo = normalizeRepoFullName(addRepo); + const canAdd = normalizedAddRepo !== null && !configured.has(normalizedAddRepo); + const active = + detail.data ?? instructions.data?.find((s) => s.full_name === selected) ?? null; + const dirty = active != null && draft !== (active.instructions ?? ""); + + const handleAdd = () => { + if (!normalizedAddRepo || !canAdd) return; + void create + .mutateAsync(normalizedAddRepo) + .then(() => setAddRepo("")) + .catch(() => undefined); + }; + + const githubReauth = + (repos.isError && isGithubReauthError(repos.error)) || + (error !== null && /github token|re-login required/i.test(error)); + + return ( +
+ {githubReauth && ( +
+ Your GitHub connection expired.{" "} + + Sign in with GitHub again + {" "} + to list installed repos. +
+ )} +
+
+ +
+ setAddRepo(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleAdd(); + } + }} + className="sm:flex-1" + /> + +
+ {suggestedRepos.length > 0 && ( + r.full_name)} + value={addRepo} + onValueChange={(v) => setAddRepo(typeof v === "string" ? v : "")} + > + + + + No matches + {suggestedRepos.map((r) => ( + + {r.full_name} + {r.private && ( + + private + + )} + + ))} + + + + )} +
+ +
+

Repositories

+ {(instructions.data ?? []).length === 0 ? ( +

No repositories yet.

+ ) : ( +
    + {(instructions.data ?? []).map((s) => ( +
  • + +
  • + ))} +
+ )} +
+
+ +
+ +
+ {!selected || !active ? ( +

+ Select a repository above to view or edit its custom agent instructions. +

+ ) : ( + <> +

{active.full_name}

+
+ + {dirty && ( + + Unsaved changes + + )} + +
+ + + )} + {error &&

{error}

} +
+
+ ); +} diff --git a/ui/src/components/InstructionsEditor.tsx b/ui/src/components/InstructionsEditor.tsx new file mode 100644 index 00000000..1d1da4c4 --- /dev/null +++ b/ui/src/components/InstructionsEditor.tsx @@ -0,0 +1,59 @@ +import Editor from "@monaco-editor/react"; +import { useEffect, useState } from "react"; + +import { Textarea } from "@/components/ui/textarea"; + +interface InstructionsEditorProps { + value: string; + onChange: (value: string) => void; + disabled?: boolean; + placeholder?: string; +} + +/** Monaco-backed markdown editor that falls back to a textarea before mount (SSR-safe). */ +export function InstructionsEditor({ + value, + onChange, + disabled, + placeholder, +}: InstructionsEditorProps) { + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + if (!mounted) { + return ( +