mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 12:43:16 +00:00
feat: per-repo custom instructions for the coding agent (#1460)
* feat: per-repo custom instructions for the coding agent Adds per-repository custom instructions for the main coding agent, mirroring the reviewer's per-repo style prompts. Instructions are stored in the LangGraph Store, managed via dashboard API + UI (Monaco editor), and appended to the agent's system prompt for runs targeting that repo. Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> * chore: wire agent instructions route into generated route tree Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> * fix: enforce repo access on instruction routes Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com> --------- Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
parent
d9f673f5a5
commit
da8f003933
13 changed files with 847 additions and 5 deletions
113
agent/dashboard/agent_instructions.py
Normal file
113
agent/dashboard/agent_instructions.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
155
tests/test_agent_instructions.py
Normal file
155
tests/test_agent_instructions.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
272
ui/src/components/AgentInstructionsPanel.tsx
Normal file
272
ui/src/components/AgentInstructionsPanel.tsx
Normal file
|
|
@ -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<string | null>(null);
|
||||
const [addRepo, setAddRepo] = useState("");
|
||||
const [selected, setSelected] = useState<string | null>(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 <Skeleton className="h-40" />;
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="flex flex-col gap-6 p-4">
|
||||
{githubReauth && (
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive">
|
||||
Your GitHub connection expired.{" "}
|
||||
<a href={loginUrl()} className="font-medium underline underline-offset-2">
|
||||
Sign in with GitHub again
|
||||
</a>{" "}
|
||||
to list installed repos.
|
||||
</div>
|
||||
)}
|
||||
<section className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="add-instruction-repo">Add repository</Label>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-end">
|
||||
<Input
|
||||
id="add-instruction-repo"
|
||||
placeholder="owner/repo"
|
||||
value={addRepo}
|
||||
onChange={(e) => setAddRepo(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
handleAdd();
|
||||
}
|
||||
}}
|
||||
className="sm:flex-1"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
className="shrink-0 sm:w-auto"
|
||||
disabled={!canAdd || create.isPending}
|
||||
onClick={handleAdd}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
{suggestedRepos.length > 0 && (
|
||||
<Combobox
|
||||
items={suggestedRepos.map((r) => r.full_name)}
|
||||
value={addRepo}
|
||||
onValueChange={(v) => setAddRepo(typeof v === "string" ? v : "")}
|
||||
>
|
||||
<ComboboxInput
|
||||
placeholder="Search installed repos…"
|
||||
showClear
|
||||
className="w-full"
|
||||
/>
|
||||
<ComboboxContent className="min-w-[var(--anchor-width)]">
|
||||
<ComboboxList className="max-h-48">
|
||||
<ComboboxEmpty>No matches</ComboboxEmpty>
|
||||
{suggestedRepos.map((r) => (
|
||||
<ComboboxItem key={r.full_name} value={r.full_name}>
|
||||
<span className="truncate">{r.full_name}</span>
|
||||
{r.private && (
|
||||
<span className="ml-auto text-[10px] text-muted-foreground">
|
||||
private
|
||||
</span>
|
||||
)}
|
||||
</ComboboxItem>
|
||||
))}
|
||||
</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-foreground">Repositories</p>
|
||||
{(instructions.data ?? []).length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">No repositories yet.</p>
|
||||
) : (
|
||||
<ul className="flex flex-wrap gap-2">
|
||||
{(instructions.data ?? []).map((s) => (
|
||||
<li key={s.full_name}>
|
||||
<button
|
||||
type="button"
|
||||
className={`inline-flex max-w-full items-center gap-2 rounded-md border px-2.5 py-1.5 text-left text-xs transition-colors hover:bg-muted ${
|
||||
selected === s.full_name
|
||||
? "border-primary bg-muted font-medium"
|
||||
: "border-border"
|
||||
}`}
|
||||
onClick={() => setSelected(s.full_name)}
|
||||
>
|
||||
<span className="truncate">{s.full_name}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="border-t border-border" />
|
||||
|
||||
<section className="space-y-3">
|
||||
{!selected || !active ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Select a repository above to view or edit its custom agent instructions.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm font-medium text-foreground">{active.full_name}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!dirty || save.isPending}
|
||||
onClick={() =>
|
||||
void save.mutateAsync({
|
||||
full_name: active.full_name,
|
||||
value: draft,
|
||||
})
|
||||
}
|
||||
>
|
||||
Save instructions
|
||||
</Button>
|
||||
{dirty && (
|
||||
<span className="self-center text-xs text-muted-foreground">
|
||||
Unsaved changes
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
className="ml-auto"
|
||||
disabled={remove.isPending}
|
||||
onClick={() => {
|
||||
if (
|
||||
!window.confirm(
|
||||
`Remove custom instructions for ${active.full_name}? This cannot be undone.`,
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
void remove.mutateAsync(active.full_name);
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
<InstructionsEditor
|
||||
value={draft}
|
||||
onChange={setDraft}
|
||||
placeholder="Write custom instructions for the coding agent on this repository (markdown)."
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
59
ui/src/components/InstructionsEditor.tsx
Normal file
59
ui/src/components/InstructionsEditor.tsx
Normal file
|
|
@ -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 (
|
||||
<Textarea
|
||||
className="min-h-[360px] w-full font-mono text-xs"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overflow-hidden rounded-md border border-border">
|
||||
<Editor
|
||||
height="360px"
|
||||
defaultLanguage="markdown"
|
||||
value={value}
|
||||
onChange={(v) => onChange(v ?? "")}
|
||||
options={{
|
||||
readOnly: disabled,
|
||||
minimap: { enabled: false },
|
||||
wordWrap: "on",
|
||||
fontSize: 12,
|
||||
lineNumbers: "on",
|
||||
scrollBeyondLastLine: false,
|
||||
padding: { top: 12, bottom: 12 },
|
||||
renderLineHighlight: "none",
|
||||
}}
|
||||
theme="vs-dark"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -225,6 +225,16 @@ export interface ReviewStyle {
|
|||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface AgentInstructions {
|
||||
full_name: string;
|
||||
owner?: string;
|
||||
name?: string;
|
||||
instructions: string;
|
||||
created_by?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
me: () => request<SessionUser>("/me"),
|
||||
options: () => request<OptionsPayload>("/options"),
|
||||
|
|
@ -257,6 +267,23 @@ export const api = {
|
|||
request<void>(`/review-styles/${encodeURIComponent(full_name)}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
listAgentInstructions: () => request<Array<AgentInstructions>>("/agent-instructions"),
|
||||
createAgentInstructions: (full_name: string) =>
|
||||
request<AgentInstructions>("/agent-instructions", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ full_name }),
|
||||
}),
|
||||
getAgentInstructions: (full_name: string) =>
|
||||
request<AgentInstructions>(`/agent-instructions/${encodeURIComponent(full_name)}`),
|
||||
saveAgentInstructions: (full_name: string, instructions: string) =>
|
||||
request<AgentInstructions>(`/agent-instructions/${encodeURIComponent(full_name)}`, {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({ instructions }),
|
||||
}),
|
||||
deleteAgentInstructions: (full_name: string) =>
|
||||
request<void>(`/agent-instructions/${encodeURIComponent(full_name)}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
getTeamSettings: () => request<TeamSettings>("/team-settings"),
|
||||
saveTeamSettings: (body: TeamSettings) =>
|
||||
request<TeamSettings>("/team-settings", { method: "PUT", body: JSON.stringify(body) }),
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { Route as AdminRouteImport } from './routes/admin'
|
|||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as AgentsIndexRouteImport } from './routes/agents/index'
|
||||
import { Route as ReviewStylesRouteImport } from './routes/review_.styles'
|
||||
import { Route as AgentsInstructionsRouteImport } from './routes/agents_.instructions'
|
||||
import { Route as AgentsThreadIdRouteImport } from './routes/agents/$threadId'
|
||||
import { Route as AgentsAutomationsIndexRouteImport } from './routes/agents/automations/index'
|
||||
import { Route as ReviewRepositoriesOwnerRouteImport } from './routes/review_.repositories.$owner'
|
||||
|
|
@ -81,6 +82,11 @@ const ReviewStylesRoute = ReviewStylesRouteImport.update({
|
|||
path: '/review/styles',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AgentsInstructionsRoute = AgentsInstructionsRouteImport.update({
|
||||
id: '/agents_/instructions',
|
||||
path: '/agents/instructions',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AgentsThreadIdRoute = AgentsThreadIdRouteImport.update({
|
||||
id: '/$threadId',
|
||||
path: '/$threadId',
|
||||
|
|
@ -120,6 +126,7 @@ export interface FileRoutesByFullPath {
|
|||
'/usage': typeof UsageRoute
|
||||
'/agents/$threadId': typeof AgentsThreadIdRoute
|
||||
'/review/styles': typeof ReviewStylesRoute
|
||||
'/agents/instructions': typeof AgentsInstructionsRoute
|
||||
'/agents/': typeof AgentsIndexRoute
|
||||
'/agents/automations/$scheduleId': typeof AgentsAutomationsScheduleIdRoute
|
||||
'/agents/automations/new': typeof AgentsAutomationsNewRoute
|
||||
|
|
@ -137,6 +144,7 @@ export interface FileRoutesByTo {
|
|||
'/usage': typeof UsageRoute
|
||||
'/agents/$threadId': typeof AgentsThreadIdRoute
|
||||
'/review/styles': typeof ReviewStylesRoute
|
||||
'/agents/instructions': typeof AgentsInstructionsRoute
|
||||
'/agents': typeof AgentsIndexRoute
|
||||
'/agents/automations/$scheduleId': typeof AgentsAutomationsScheduleIdRoute
|
||||
'/agents/automations/new': typeof AgentsAutomationsNewRoute
|
||||
|
|
@ -156,6 +164,7 @@ export interface FileRoutesById {
|
|||
'/usage': typeof UsageRoute
|
||||
'/agents/$threadId': typeof AgentsThreadIdRoute
|
||||
'/review_/styles': typeof ReviewStylesRoute
|
||||
'/agents_/instructions': typeof AgentsInstructionsRoute
|
||||
'/agents/': typeof AgentsIndexRoute
|
||||
'/agents/automations/$scheduleId': typeof AgentsAutomationsScheduleIdRoute
|
||||
'/agents/automations/new': typeof AgentsAutomationsNewRoute
|
||||
|
|
@ -176,6 +185,7 @@ export interface FileRouteTypes {
|
|||
| '/usage'
|
||||
| '/agents/$threadId'
|
||||
| '/review/styles'
|
||||
| '/agents/instructions'
|
||||
| '/agents/'
|
||||
| '/agents/automations/$scheduleId'
|
||||
| '/agents/automations/new'
|
||||
|
|
@ -193,6 +203,7 @@ export interface FileRouteTypes {
|
|||
| '/usage'
|
||||
| '/agents/$threadId'
|
||||
| '/review/styles'
|
||||
| '/agents/instructions'
|
||||
| '/agents'
|
||||
| '/agents/automations/$scheduleId'
|
||||
| '/agents/automations/new'
|
||||
|
|
@ -211,6 +222,7 @@ export interface FileRouteTypes {
|
|||
| '/usage'
|
||||
| '/agents/$threadId'
|
||||
| '/review_/styles'
|
||||
| '/agents_/instructions'
|
||||
| '/agents/'
|
||||
| '/agents/automations/$scheduleId'
|
||||
| '/agents/automations/new'
|
||||
|
|
@ -229,6 +241,7 @@ export interface RootRouteChildren {
|
|||
ReviewRoute: typeof ReviewRoute
|
||||
UsageRoute: typeof UsageRoute
|
||||
ReviewStylesRoute: typeof ReviewStylesRoute
|
||||
AgentsInstructionsRoute: typeof AgentsInstructionsRoute
|
||||
ReviewRepositoriesOwnerRoute: typeof ReviewRepositoriesOwnerRoute
|
||||
}
|
||||
|
||||
|
|
@ -311,6 +324,13 @@ declare module '@tanstack/react-router' {
|
|||
preLoaderRoute: typeof ReviewStylesRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/agents_/instructions': {
|
||||
id: '/agents_/instructions'
|
||||
path: '/agents/instructions'
|
||||
fullPath: '/agents/instructions'
|
||||
preLoaderRoute: typeof AgentsInstructionsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/agents/$threadId': {
|
||||
id: '/agents/$threadId'
|
||||
path: '/$threadId'
|
||||
|
|
@ -379,6 +399,7 @@ const rootRouteChildren: RootRouteChildren = {
|
|||
ReviewRoute: ReviewRoute,
|
||||
UsageRoute: UsageRoute,
|
||||
ReviewStylesRoute: ReviewStylesRoute,
|
||||
AgentsInstructionsRoute: AgentsInstructionsRoute,
|
||||
ReviewRepositoriesOwnerRoute: ReviewRepositoriesOwnerRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
|
|
|
|||
36
ui/src/routes/agents_.instructions.tsx
Normal file
36
ui/src/routes/agents_.instructions.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { Navigate, createFileRoute } from "@tanstack/react-router";
|
||||
|
||||
import { AgentInstructionsPanel } from "@/components/AgentInstructionsPanel";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { useSession } from "@/lib/session";
|
||||
|
||||
export const Route = createFileRoute("/agents_/instructions")({
|
||||
component: AgentInstructionsPage,
|
||||
});
|
||||
|
||||
function AgentInstructionsPage() {
|
||||
const session = useSession();
|
||||
|
||||
if (session.isLoading) {
|
||||
return (
|
||||
<main className="p-6">
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
if (!session.data) return <Navigate to="/login" />;
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
user={session.data}
|
||||
title="Repository Instructions"
|
||||
description="Per-repo custom instructions appended to the coding agent's system prompt for runs targeting that repository."
|
||||
backTo={{ to: "/cloud-agents", label: "Back to Open SWE Agent" }}
|
||||
>
|
||||
<div className="rounded-lg border border-border bg-card">
|
||||
<AgentInstructionsPanel />
|
||||
</div>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { Navigate, createFileRoute } from "@tanstack/react-router"
|
||||
import { Link, Navigate, createFileRoute } from "@tanstack/react-router"
|
||||
import { CaretRightIcon } from "@phosphor-icons/react"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
|
||||
import type { ModelOption } from "@/lib/api"
|
||||
|
|
@ -316,6 +317,21 @@ function CloudAgentsPage() {
|
|||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Rules">
|
||||
<Link
|
||||
to="/agents/instructions"
|
||||
className="flex items-center justify-between gap-6 px-4 py-3 hover:bg-muted/40"
|
||||
>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span className="text-xs font-medium text-foreground">Repository Instructions</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Per-repo custom instructions injected into the agent's system prompt.
|
||||
</span>
|
||||
</div>
|
||||
<CaretRightIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
</Link>
|
||||
</SettingsSection>
|
||||
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
</AppShell>
|
||||
)
|
||||
|
|
|
|||
24
ui/yarn.lock
24
ui/yarn.lock
|
|
@ -1617,6 +1617,20 @@
|
|||
resolved "https://registry.yarnpkg.com/@oxc-project/types/-/types-0.133.0.tgz#2e282ef9e1d26e06b68ccd14b73f310a3b2cf7f8"
|
||||
integrity sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==
|
||||
|
||||
"@monaco-editor/loader@^1.5.0":
|
||||
version "1.7.0"
|
||||
resolved "https://registry.yarnpkg.com/@monaco-editor/loader/-/loader-1.7.0.tgz#967aaa4601b19e913627688dfe8159d57549e793"
|
||||
integrity sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==
|
||||
dependencies:
|
||||
state-local "^1.0.6"
|
||||
|
||||
"@monaco-editor/react@^4.7.0":
|
||||
version "4.7.0"
|
||||
resolved "https://registry.yarnpkg.com/@monaco-editor/react/-/react-4.7.0.tgz#35a1ec01bfe729f38bfc025df7b7bac145602a60"
|
||||
integrity sha512-cyzXQCtO47ydzxpQtCGSQGOC8Gk3ZUeBXFAxD+CWXYFo5OqZyZUonFl0DwUlTyAfRHntBfw2p3w4s9R6oe1eCA==
|
||||
dependencies:
|
||||
"@monaco-editor/loader" "^1.5.0"
|
||||
|
||||
"@package-json/types@^0.0.12":
|
||||
version "0.0.12"
|
||||
resolved "https://registry.yarnpkg.com/@package-json/types/-/types-0.0.12.tgz#4629e833ba128ed9880b6b7a947633ee22952462"
|
||||
|
|
@ -6270,6 +6284,11 @@ minipass@^7.1.2:
|
|||
resolved "https://registry.yarnpkg.com/minipass/-/minipass-7.1.3.tgz#79389b4eb1bb2d003a9bba87d492f2bd37bdc65b"
|
||||
integrity sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==
|
||||
|
||||
monaco-editor@^0.52.2:
|
||||
version "0.52.2"
|
||||
resolved "https://registry.yarnpkg.com/monaco-editor/-/monaco-editor-0.52.2.tgz#53c75a6fcc6802684e99fd1b2700299857002205"
|
||||
integrity sha512-GEQWEZmfkOGLdd3XK8ryrfWz3AIP8YymVXiPHEdewrUq7mh0qrKrfHLNCXcbB6sTnMLnOZ3ztSiKcciFUkIJwQ==
|
||||
|
||||
ms@^2.1.3:
|
||||
version "2.1.3"
|
||||
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
|
||||
|
|
@ -7516,6 +7535,11 @@ stackback@0.0.2:
|
|||
resolved "https://registry.yarnpkg.com/stackback/-/stackback-0.0.2.tgz#1ac8a0d9483848d1695e418b6d031a3c3ce68e3b"
|
||||
integrity sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==
|
||||
|
||||
state-local@^1.0.6:
|
||||
version "1.0.7"
|
||||
resolved "https://registry.yarnpkg.com/state-local/-/state-local-1.0.7.tgz#da50211d07f05748d53009bee46307a37db386d5"
|
||||
integrity sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==
|
||||
|
||||
statuses@^2.0.1, statuses@^2.0.2, statuses@~2.0.2:
|
||||
version "2.0.2"
|
||||
resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.2.tgz#8f75eecef765b5e1cfcdc080da59409ed424e382"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue