refactor: clean up code formatting and improve readability in various files
This commit is contained in:
@@ -318,9 +318,13 @@ async def delete_board(
|
|||||||
await session.execute(
|
await session.execute(
|
||||||
delete(BoardOnboardingSession).where(col(BoardOnboardingSession.board_id) == board.id)
|
delete(BoardOnboardingSession).where(col(BoardOnboardingSession.board_id) == board.id)
|
||||||
)
|
)
|
||||||
await session.execute(delete(OrganizationBoardAccess).where(col(OrganizationBoardAccess.board_id) == board.id))
|
|
||||||
await session.execute(
|
await session.execute(
|
||||||
delete(OrganizationInviteBoardAccess).where(col(OrganizationInviteBoardAccess.board_id) == board.id)
|
delete(OrganizationBoardAccess).where(col(OrganizationBoardAccess.board_id) == board.id)
|
||||||
|
)
|
||||||
|
await session.execute(
|
||||||
|
delete(OrganizationInviteBoardAccess).where(
|
||||||
|
col(OrganizationInviteBoardAccess.board_id) == board.id
|
||||||
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
# Tasks reference agents (assigned_agent_id) and have dependents (fingerprints/dependencies), so
|
# Tasks reference agents (assigned_agent_id) and have dependents (fingerprints/dependencies), so
|
||||||
|
|||||||
@@ -99,15 +99,21 @@ def test_role_rank_unknown_role_falls_back_to_member_rank() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_is_org_admin_owner_admin_member() -> None:
|
def test_is_org_admin_owner_admin_member() -> None:
|
||||||
assert organizations.is_org_admin(OrganizationMember(organization_id=uuid4(), user_id=uuid4(), role="owner"))
|
assert organizations.is_org_admin(
|
||||||
assert organizations.is_org_admin(OrganizationMember(organization_id=uuid4(), user_id=uuid4(), role="admin"))
|
OrganizationMember(organization_id=uuid4(), user_id=uuid4(), role="owner")
|
||||||
|
)
|
||||||
|
assert organizations.is_org_admin(
|
||||||
|
OrganizationMember(organization_id=uuid4(), user_id=uuid4(), role="admin")
|
||||||
|
)
|
||||||
assert not organizations.is_org_admin(
|
assert not organizations.is_org_admin(
|
||||||
OrganizationMember(organization_id=uuid4(), user_id=uuid4(), role="member")
|
OrganizationMember(organization_id=uuid4(), user_id=uuid4(), role="member")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_ensure_member_for_user_returns_existing_membership(monkeypatch: pytest.MonkeyPatch) -> None:
|
async def test_ensure_member_for_user_returns_existing_membership(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
user = User(clerk_user_id="u1")
|
user = User(clerk_user_id="u1")
|
||||||
existing = OrganizationMember(organization_id=uuid4(), user_id=user.id, role="member")
|
existing = OrganizationMember(organization_id=uuid4(), user_id=user.id, role="member")
|
||||||
|
|
||||||
@@ -122,7 +128,9 @@ async def test_ensure_member_for_user_returns_existing_membership(monkeypatch: p
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_ensure_member_for_user_accepts_pending_invite(monkeypatch: pytest.MonkeyPatch) -> None:
|
async def test_ensure_member_for_user_accepts_pending_invite(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
org_id = uuid4()
|
org_id = uuid4()
|
||||||
invite = OrganizationInvite(
|
invite = OrganizationInvite(
|
||||||
organization_id=org_id,
|
organization_id=org_id,
|
||||||
@@ -140,7 +148,9 @@ async def test_ensure_member_for_user_accepts_pending_invite(monkeypatch: pytest
|
|||||||
|
|
||||||
accepted = OrganizationMember(organization_id=org_id, user_id=user.id, role="member")
|
accepted = OrganizationMember(organization_id=org_id, user_id=user.id, role="member")
|
||||||
|
|
||||||
async def _fake_accept(_session: Any, _invite: OrganizationInvite, _user: User) -> OrganizationMember:
|
async def _fake_accept(
|
||||||
|
_session: Any, _invite: OrganizationInvite, _user: User
|
||||||
|
) -> OrganizationMember:
|
||||||
assert _invite is invite
|
assert _invite is invite
|
||||||
assert _user is user
|
assert _user is user
|
||||||
return accepted
|
return accepted
|
||||||
@@ -155,7 +165,9 @@ async def test_ensure_member_for_user_accepts_pending_invite(monkeypatch: pytest
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_ensure_member_for_user_creates_default_org_and_first_owner(monkeypatch: pytest.MonkeyPatch) -> None:
|
async def test_ensure_member_for_user_creates_default_org_and_first_owner(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
user = User(clerk_user_id="u1", email=None)
|
user = User(clerk_user_id="u1", email=None)
|
||||||
org = Organization(id=uuid4(), name=organizations.DEFAULT_ORG_NAME)
|
org = Organization(id=uuid4(), name=organizations.DEFAULT_ORG_NAME)
|
||||||
|
|
||||||
@@ -186,7 +198,10 @@ async def test_has_board_access_denies_cross_org() -> None:
|
|||||||
session = _FakeSession(exec_results=[])
|
session = _FakeSession(exec_results=[])
|
||||||
member = OrganizationMember(organization_id=uuid4(), user_id=uuid4(), role="member")
|
member = OrganizationMember(organization_id=uuid4(), user_id=uuid4(), role="member")
|
||||||
board = Board(id=uuid4(), organization_id=uuid4(), name="b", slug="b")
|
board = Board(id=uuid4(), organization_id=uuid4(), name="b", slug="b")
|
||||||
assert await organizations.has_board_access(session, member=member, board=board, write=False) is False
|
assert (
|
||||||
|
await organizations.has_board_access(session, member=member, board=board, write=False)
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -202,7 +217,10 @@ async def test_has_board_access_uses_org_board_access_row_read_and_write() -> No
|
|||||||
can_write=False,
|
can_write=False,
|
||||||
)
|
)
|
||||||
session = _FakeSession(exec_results=[_FakeExecResult(first_value=access)])
|
session = _FakeSession(exec_results=[_FakeExecResult(first_value=access)])
|
||||||
assert await organizations.has_board_access(session, member=member, board=board, write=False) is True
|
assert (
|
||||||
|
await organizations.has_board_access(session, member=member, board=board, write=False)
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
|
||||||
access2 = OrganizationBoardAccess(
|
access2 = OrganizationBoardAccess(
|
||||||
organization_member_id=member.id,
|
organization_member_id=member.id,
|
||||||
@@ -211,7 +229,10 @@ async def test_has_board_access_uses_org_board_access_row_read_and_write() -> No
|
|||||||
can_write=True,
|
can_write=True,
|
||||||
)
|
)
|
||||||
session2 = _FakeSession(exec_results=[_FakeExecResult(first_value=access2)])
|
session2 = _FakeSession(exec_results=[_FakeExecResult(first_value=access2)])
|
||||||
assert await organizations.has_board_access(session2, member=member, board=board, write=False) is True
|
assert (
|
||||||
|
await organizations.has_board_access(session2, member=member, board=board, write=False)
|
||||||
|
is True
|
||||||
|
)
|
||||||
|
|
||||||
access3 = OrganizationBoardAccess(
|
access3 = OrganizationBoardAccess(
|
||||||
organization_member_id=member.id,
|
organization_member_id=member.id,
|
||||||
@@ -220,7 +241,10 @@ async def test_has_board_access_uses_org_board_access_row_read_and_write() -> No
|
|||||||
can_write=False,
|
can_write=False,
|
||||||
)
|
)
|
||||||
session3 = _FakeSession(exec_results=[_FakeExecResult(first_value=access3)])
|
session3 = _FakeSession(exec_results=[_FakeExecResult(first_value=access3)])
|
||||||
assert await organizations.has_board_access(session3, member=member, board=board, write=True) is False
|
assert (
|
||||||
|
await organizations.has_board_access(session3, member=member, board=board, write=True)
|
||||||
|
is False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -240,7 +264,9 @@ async def test_require_board_access_raises_when_no_member(monkeypatch: pytest.Mo
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_apply_member_access_update_deletes_existing_and_adds_rows_when_not_all_boards() -> None:
|
async def test_apply_member_access_update_deletes_existing_and_adds_rows_when_not_all_boards() -> (
|
||||||
|
None
|
||||||
|
):
|
||||||
member = OrganizationMember(id=uuid4(), organization_id=uuid4(), user_id=uuid4(), role="member")
|
member = OrganizationMember(id=uuid4(), organization_id=uuid4(), user_id=uuid4(), role="member")
|
||||||
update = OrganizationMemberAccessUpdate(
|
update = OrganizationMemberAccessUpdate(
|
||||||
all_boards_read=False,
|
all_boards_read=False,
|
||||||
@@ -263,7 +289,9 @@ async def test_apply_member_access_update_deletes_existing_and_adds_rows_when_no
|
|||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_apply_invite_to_member_upgrades_role_and_merges_access_rows(monkeypatch: pytest.MonkeyPatch) -> None:
|
async def test_apply_invite_to_member_upgrades_role_and_merges_access_rows(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
org_id = uuid4()
|
org_id = uuid4()
|
||||||
member = OrganizationMember(
|
member = OrganizationMember(
|
||||||
id=uuid4(),
|
id=uuid4(),
|
||||||
@@ -294,10 +322,12 @@ async def test_apply_invite_to_member_upgrades_role_and_merges_access_rows(monke
|
|||||||
|
|
||||||
# 1st exec: invite access rows list
|
# 1st exec: invite access rows list
|
||||||
# 2nd exec: existing access (none)
|
# 2nd exec: existing access (none)
|
||||||
session = _FakeSession(exec_results=[
|
session = _FakeSession(
|
||||||
[invite_access],
|
exec_results=[
|
||||||
_FakeExecResult(first_value=None),
|
[invite_access],
|
||||||
])
|
_FakeExecResult(first_value=None),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
await organizations.apply_invite_to_member(session, member=member, invite=invite)
|
await organizations.apply_invite_to_member(session, member=member, invite=invite)
|
||||||
|
|
||||||
|
|||||||
@@ -130,7 +130,9 @@ async def test_dependency_queries_and_replace_and_dependents() -> None:
|
|||||||
# cover empty input short-circuit
|
# cover empty input short-circuit
|
||||||
assert await td.dependency_status_by_id(session, board_id=board_id, dependency_ids=[]) == {}
|
assert await td.dependency_status_by_id(session, board_id=board_id, dependency_ids=[]) == {}
|
||||||
|
|
||||||
status_map = await td.dependency_status_by_id(session, board_id=board_id, dependency_ids=[t2, t3])
|
status_map = await td.dependency_status_by_id(
|
||||||
|
session, board_id=board_id, dependency_ids=[t2, t3]
|
||||||
|
)
|
||||||
assert status_map[t2] == td.DONE_STATUS
|
assert status_map[t2] == td.DONE_STATUS
|
||||||
assert status_map[t3] != td.DONE_STATUS
|
assert status_map[t3] != td.DONE_STATUS
|
||||||
|
|
||||||
|
|||||||
@@ -285,241 +285,237 @@ export default function EditAgentPage() {
|
|||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm space-y-6"
|
className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm space-y-6"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||||
Basic configuration
|
Basic configuration
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-4 space-y-6">
|
<div className="mt-4 space-y-6">
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-900">
|
<label className="text-sm font-medium text-slate-900">
|
||||||
Agent name <span className="text-red-500">*</span>
|
Agent name <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
value={resolvedName}
|
value={resolvedName}
|
||||||
onChange={(event) => setName(event.target.value)}
|
onChange={(event) => setName(event.target.value)}
|
||||||
placeholder="e.g. Deploy bot"
|
placeholder="e.g. Deploy bot"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-900">
|
<label className="text-sm font-medium text-slate-900">
|
||||||
Role
|
Role
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
value={resolvedIdentityProfile.role}
|
value={resolvedIdentityProfile.role}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setIdentityProfile({
|
setIdentityProfile({
|
||||||
...resolvedIdentityProfile,
|
...resolvedIdentityProfile,
|
||||||
role: event.target.value,
|
role: event.target.value,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
placeholder="e.g. Founder, Social Media Manager"
|
placeholder="e.g. Founder, Social Media Manager"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className="text-sm font-medium text-slate-900">
|
<label className="text-sm font-medium text-slate-900">
|
||||||
Board
|
Board
|
||||||
{resolvedIsGatewayMain ? (
|
{resolvedIsGatewayMain ? (
|
||||||
<span className="ml-2 text-xs font-normal text-slate-500">
|
<span className="ml-2 text-xs font-normal text-slate-500">
|
||||||
optional
|
optional
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="text-red-500"> *</span>
|
|
||||||
)}
|
|
||||||
</label>
|
|
||||||
{resolvedBoardId ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-xs font-medium text-slate-600 hover:text-slate-900"
|
|
||||||
onClick={() => {
|
|
||||||
setBoardId("");
|
|
||||||
}}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
Clear board
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<SearchableSelect
|
|
||||||
ariaLabel="Select board"
|
|
||||||
value={resolvedBoardId}
|
|
||||||
onValueChange={(value) => setBoardId(value)}
|
|
||||||
options={getBoardOptions(boards)}
|
|
||||||
placeholder={
|
|
||||||
resolvedIsGatewayMain
|
|
||||||
? "No board (main agent)"
|
|
||||||
: "Select board"
|
|
||||||
}
|
|
||||||
searchPlaceholder="Search boards..."
|
|
||||||
emptyMessage="No matching boards."
|
|
||||||
triggerClassName="w-full h-11 rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
|
|
||||||
contentClassName="rounded-xl border border-slate-200 shadow-lg"
|
|
||||||
itemClassName="px-4 py-3 text-sm text-slate-700 data-[selected=true]:bg-slate-50 data-[selected=true]:text-slate-900"
|
|
||||||
disabled={boards.length === 0}
|
|
||||||
/>
|
|
||||||
{resolvedIsGatewayMain ? (
|
|
||||||
<p className="text-xs text-slate-500">
|
|
||||||
Main agents are not attached to a board. If a board is
|
|
||||||
selected, it is only used to resolve the gateway main
|
|
||||||
session key and will be cleared on save.
|
|
||||||
</p>
|
|
||||||
) : boards.length === 0 ? (
|
|
||||||
<p className="text-xs text-slate-500">
|
|
||||||
Create a board before assigning agents.
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-slate-900">
|
|
||||||
Emoji
|
|
||||||
</label>
|
|
||||||
<Select
|
|
||||||
value={resolvedIdentityProfile.emoji}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
setIdentityProfile({
|
|
||||||
...resolvedIdentityProfile,
|
|
||||||
emoji: value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select emoji" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{EMOJI_OPTIONS.map((option) => (
|
|
||||||
<SelectItem key={option.value} value={option.value}>
|
|
||||||
{option.glyph} {option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-6 rounded-xl border border-slate-200 bg-slate-50 p-4">
|
|
||||||
<label className="flex items-start gap-3 text-sm text-slate-700">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="mt-1 h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-blue-200"
|
|
||||||
checked={resolvedIsGatewayMain}
|
|
||||||
onChange={(event) =>
|
|
||||||
setIsGatewayMain(event.target.checked)
|
|
||||||
}
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
<span>
|
|
||||||
<span className="block font-medium text-slate-900">
|
|
||||||
Gateway main agent
|
|
||||||
</span>
|
</span>
|
||||||
<span className="block text-xs text-slate-500">
|
) : (
|
||||||
Uses the gateway main session key and is not tied to a
|
<span className="text-red-500"> *</span>
|
||||||
single board.
|
)}
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
</label>
|
||||||
|
{resolvedBoardId ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-xs font-medium text-slate-600 hover:text-slate-900"
|
||||||
|
onClick={() => {
|
||||||
|
setBoardId("");
|
||||||
|
}}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
Clear board
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
<SearchableSelect
|
||||||
|
ariaLabel="Select board"
|
||||||
|
value={resolvedBoardId}
|
||||||
|
onValueChange={(value) => setBoardId(value)}
|
||||||
|
options={getBoardOptions(boards)}
|
||||||
|
placeholder={
|
||||||
|
resolvedIsGatewayMain
|
||||||
|
? "No board (main agent)"
|
||||||
|
: "Select board"
|
||||||
|
}
|
||||||
|
searchPlaceholder="Search boards..."
|
||||||
|
emptyMessage="No matching boards."
|
||||||
|
triggerClassName="w-full h-11 rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
|
||||||
|
contentClassName="rounded-xl border border-slate-200 shadow-lg"
|
||||||
|
itemClassName="px-4 py-3 text-sm text-slate-700 data-[selected=true]:bg-slate-50 data-[selected=true]:text-slate-900"
|
||||||
|
disabled={boards.length === 0}
|
||||||
|
/>
|
||||||
|
{resolvedIsGatewayMain ? (
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Main agents are not attached to a board. If a board is
|
||||||
|
selected, it is only used to resolve the gateway main
|
||||||
|
session key and will be cleared on save.
|
||||||
|
</p>
|
||||||
|
) : boards.length === 0 ? (
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Create a board before assigning agents.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
<div>
|
<label className="text-sm font-medium text-slate-900">
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
Emoji
|
||||||
Personality & behavior
|
</label>
|
||||||
</p>
|
<Select
|
||||||
<div className="mt-4 space-y-6">
|
value={resolvedIdentityProfile.emoji}
|
||||||
<div className="space-y-2">
|
onValueChange={(value) =>
|
||||||
<label className="text-sm font-medium text-slate-900">
|
setIdentityProfile({
|
||||||
Communication style
|
...resolvedIdentityProfile,
|
||||||
</label>
|
emoji: value,
|
||||||
<Input
|
})
|
||||||
value={resolvedIdentityProfile.communication_style}
|
}
|
||||||
onChange={(event) =>
|
disabled={isLoading}
|
||||||
setIdentityProfile({
|
|
||||||
...resolvedIdentityProfile,
|
|
||||||
communication_style: event.target.value,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-slate-900">
|
|
||||||
Soul template
|
|
||||||
</label>
|
|
||||||
<Textarea
|
|
||||||
value={resolvedSoulTemplate}
|
|
||||||
onChange={(event) => setSoulTemplate(event.target.value)}
|
|
||||||
rows={10}
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
|
||||||
Schedule & notifications
|
|
||||||
</p>
|
|
||||||
<div className="mt-4 grid gap-6 md:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-slate-900">
|
|
||||||
Interval
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
value={resolvedHeartbeatEvery}
|
|
||||||
onChange={(event) =>
|
|
||||||
setHeartbeatEvery(event.target.value)
|
|
||||||
}
|
|
||||||
placeholder="e.g. 10m"
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-slate-500">
|
|
||||||
Set how often this agent runs HEARTBEAT.md.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-slate-900">
|
|
||||||
Target
|
|
||||||
</label>
|
|
||||||
<SearchableSelect
|
|
||||||
ariaLabel="Select heartbeat target"
|
|
||||||
value={resolvedHeartbeatTarget}
|
|
||||||
onValueChange={setHeartbeatTarget}
|
|
||||||
options={HEARTBEAT_TARGET_OPTIONS}
|
|
||||||
placeholder="Select target"
|
|
||||||
searchPlaceholder="Search targets..."
|
|
||||||
emptyMessage="No matching targets."
|
|
||||||
triggerClassName="w-full h-11 rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
|
|
||||||
contentClassName="rounded-xl border border-slate-200 shadow-lg"
|
|
||||||
itemClassName="px-4 py-3 text-sm text-slate-700 data-[selected=true]:bg-slate-50 data-[selected=true]:text-slate-900"
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{errorMessage ? (
|
|
||||||
<div className="rounded-lg border border-slate-200 bg-white p-3 text-sm text-slate-600 shadow-sm">
|
|
||||||
{errorMessage}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
|
||||||
<Button type="submit" disabled={isLoading}>
|
|
||||||
{isLoading ? "Saving…" : "Save changes"}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
type="button"
|
|
||||||
onClick={() => router.push(`/agents/${agentId}`)}
|
|
||||||
>
|
>
|
||||||
Back to agent
|
<SelectTrigger>
|
||||||
</Button>
|
<SelectValue placeholder="Select emoji" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{EMOJI_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.glyph} {option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 rounded-xl border border-slate-200 bg-slate-50 p-4">
|
||||||
|
<label className="flex items-start gap-3 text-sm text-slate-700">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-1 h-4 w-4 rounded border-slate-300 text-blue-600 focus:ring-blue-200"
|
||||||
|
checked={resolvedIsGatewayMain}
|
||||||
|
onChange={(event) => setIsGatewayMain(event.target.checked)}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
<span className="block font-medium text-slate-900">
|
||||||
|
Gateway main agent
|
||||||
|
</span>
|
||||||
|
<span className="block text-xs text-slate-500">
|
||||||
|
Uses the gateway main session key and is not tied to a single
|
||||||
|
board.
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||||
|
Personality & behavior
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-900">
|
||||||
|
Communication style
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={resolvedIdentityProfile.communication_style}
|
||||||
|
onChange={(event) =>
|
||||||
|
setIdentityProfile({
|
||||||
|
...resolvedIdentityProfile,
|
||||||
|
communication_style: event.target.value,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-900">
|
||||||
|
Soul template
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
value={resolvedSoulTemplate}
|
||||||
|
onChange={(event) => setSoulTemplate(event.target.value)}
|
||||||
|
rows={10}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||||
|
Schedule & notifications
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 grid gap-6 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-900">
|
||||||
|
Interval
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={resolvedHeartbeatEvery}
|
||||||
|
onChange={(event) => setHeartbeatEvery(event.target.value)}
|
||||||
|
placeholder="e.g. 10m"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Set how often this agent runs HEARTBEAT.md.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-900">
|
||||||
|
Target
|
||||||
|
</label>
|
||||||
|
<SearchableSelect
|
||||||
|
ariaLabel="Select heartbeat target"
|
||||||
|
value={resolvedHeartbeatTarget}
|
||||||
|
onValueChange={setHeartbeatTarget}
|
||||||
|
options={HEARTBEAT_TARGET_OPTIONS}
|
||||||
|
placeholder="Select target"
|
||||||
|
searchPlaceholder="Search targets..."
|
||||||
|
emptyMessage="No matching targets."
|
||||||
|
triggerClassName="w-full h-11 rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
|
||||||
|
contentClassName="rounded-xl border border-slate-200 shadow-lg"
|
||||||
|
itemClassName="px-4 py-3 text-sm text-slate-700 data-[selected=true]:bg-slate-50 data-[selected=true]:text-slate-900"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{errorMessage ? (
|
||||||
|
<div className="rounded-lg border border-slate-200 bg-white p-3 text-sm text-slate-600 shadow-sm">
|
||||||
|
{errorMessage}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Button type="submit" disabled={isLoading}>
|
||||||
|
{isLoading ? "Saving…" : "Save changes"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.push(`/agents/${agentId}`)}
|
||||||
|
>
|
||||||
|
Back to agent
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</DashboardPageLayout>
|
</DashboardPageLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -167,193 +167,186 @@ export default function NewAgentPage() {
|
|||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm space-y-6"
|
className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm space-y-6"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||||
Basic configuration
|
Basic configuration
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 space-y-6">
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-900">
|
||||||
|
Agent name <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={name}
|
||||||
|
onChange={(event) => setName(event.target.value)}
|
||||||
|
placeholder="e.g. Deploy bot"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-900">
|
||||||
|
Role
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={identityProfile.role}
|
||||||
|
onChange={(event) =>
|
||||||
|
setIdentityProfile((current) => ({
|
||||||
|
...current,
|
||||||
|
role: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="e.g. Founder, Social Media Manager"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-900">
|
||||||
|
Board <span className="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<SearchableSelect
|
||||||
|
ariaLabel="Select board"
|
||||||
|
value={displayBoardId}
|
||||||
|
onValueChange={setBoardId}
|
||||||
|
options={getBoardOptions(boards)}
|
||||||
|
placeholder="Select board"
|
||||||
|
searchPlaceholder="Search boards..."
|
||||||
|
emptyMessage="No matching boards."
|
||||||
|
triggerClassName="w-full h-11 rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
|
||||||
|
contentClassName="rounded-xl border border-slate-200 shadow-lg"
|
||||||
|
itemClassName="px-4 py-3 text-sm text-slate-700 data-[selected=true]:bg-slate-50 data-[selected=true]:text-slate-900"
|
||||||
|
disabled={boards.length === 0}
|
||||||
|
/>
|
||||||
|
{boards.length === 0 ? (
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Create a board before adding agents.
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-4 space-y-6">
|
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-slate-900">
|
|
||||||
Agent name <span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
value={name}
|
|
||||||
onChange={(event) => setName(event.target.value)}
|
|
||||||
placeholder="e.g. Deploy bot"
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-slate-900">
|
|
||||||
Role
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
value={identityProfile.role}
|
|
||||||
onChange={(event) =>
|
|
||||||
setIdentityProfile((current) => ({
|
|
||||||
...current,
|
|
||||||
role: event.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
placeholder="e.g. Founder, Social Media Manager"
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-slate-900">
|
|
||||||
Board <span className="text-red-500">*</span>
|
|
||||||
</label>
|
|
||||||
<SearchableSelect
|
|
||||||
ariaLabel="Select board"
|
|
||||||
value={displayBoardId}
|
|
||||||
onValueChange={setBoardId}
|
|
||||||
options={getBoardOptions(boards)}
|
|
||||||
placeholder="Select board"
|
|
||||||
searchPlaceholder="Search boards..."
|
|
||||||
emptyMessage="No matching boards."
|
|
||||||
triggerClassName="w-full h-11 rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
|
|
||||||
contentClassName="rounded-xl border border-slate-200 shadow-lg"
|
|
||||||
itemClassName="px-4 py-3 text-sm text-slate-700 data-[selected=true]:bg-slate-50 data-[selected=true]:text-slate-900"
|
|
||||||
disabled={boards.length === 0}
|
|
||||||
/>
|
|
||||||
{boards.length === 0 ? (
|
|
||||||
<p className="text-xs text-slate-500">
|
|
||||||
Create a board before adding agents.
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-slate-900">
|
|
||||||
Emoji
|
|
||||||
</label>
|
|
||||||
<Select
|
|
||||||
value={identityProfile.emoji}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
setIdentityProfile((current) => ({
|
|
||||||
...current,
|
|
||||||
emoji: value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
<SelectTrigger>
|
|
||||||
<SelectValue placeholder="Select emoji" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{EMOJI_OPTIONS.map((option) => (
|
|
||||||
<SelectItem
|
|
||||||
key={option.value}
|
|
||||||
value={option.value}
|
|
||||||
>
|
|
||||||
{option.glyph} {option.label}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
|
||||||
Personality & behavior
|
|
||||||
</p>
|
|
||||||
<div className="mt-4 space-y-6">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-slate-900">
|
|
||||||
Communication style
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
value={identityProfile.communication_style}
|
|
||||||
onChange={(event) =>
|
|
||||||
setIdentityProfile((current) => ({
|
|
||||||
...current,
|
|
||||||
communication_style: event.target.value,
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-slate-900">
|
|
||||||
Soul template
|
|
||||||
</label>
|
|
||||||
<Textarea
|
|
||||||
value={soulTemplate}
|
|
||||||
onChange={(event) =>
|
|
||||||
setSoulTemplate(event.target.value)
|
|
||||||
}
|
|
||||||
rows={10}
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
|
||||||
Schedule & notifications
|
|
||||||
</p>
|
|
||||||
<div className="mt-4 grid gap-6 md:grid-cols-2">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-slate-900">
|
|
||||||
Interval
|
|
||||||
</label>
|
|
||||||
<Input
|
|
||||||
value={heartbeatEvery}
|
|
||||||
onChange={(event) =>
|
|
||||||
setHeartbeatEvery(event.target.value)
|
|
||||||
}
|
|
||||||
placeholder="e.g. 10m"
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-slate-500">
|
|
||||||
How often this agent runs HEARTBEAT.md (10m, 30m, 2h).
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-slate-900">
|
|
||||||
Target
|
|
||||||
</label>
|
|
||||||
<SearchableSelect
|
|
||||||
ariaLabel="Select heartbeat target"
|
|
||||||
value={heartbeatTarget}
|
|
||||||
onValueChange={setHeartbeatTarget}
|
|
||||||
options={HEARTBEAT_TARGET_OPTIONS}
|
|
||||||
placeholder="Select target"
|
|
||||||
searchPlaceholder="Search targets..."
|
|
||||||
emptyMessage="No matching targets."
|
|
||||||
triggerClassName="w-full h-11 rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
|
|
||||||
contentClassName="rounded-xl border border-slate-200 shadow-lg"
|
|
||||||
itemClassName="px-4 py-3 text-sm text-slate-700 data-[selected=true]:bg-slate-50 data-[selected=true]:text-slate-900"
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{errorMessage ? (
|
|
||||||
<div className="rounded-lg border border-slate-200 bg-white p-3 text-sm text-slate-600 shadow-sm">
|
|
||||||
{errorMessage}
|
|
||||||
</div>
|
|
||||||
) : null}
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-900">
|
||||||
|
Emoji
|
||||||
|
</label>
|
||||||
|
<Select
|
||||||
|
value={identityProfile.emoji}
|
||||||
|
onValueChange={(value) =>
|
||||||
|
setIdentityProfile((current) => ({
|
||||||
|
...current,
|
||||||
|
emoji: value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select emoji" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{EMOJI_OPTIONS.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{option.glyph} {option.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-3">
|
<div>
|
||||||
<Button type="submit" disabled={isLoading}>
|
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||||
{isLoading ? "Creating…" : "Create agent"}
|
Personality & behavior
|
||||||
</Button>
|
</p>
|
||||||
<Button
|
<div className="mt-4 space-y-6">
|
||||||
variant="outline"
|
<div className="space-y-2">
|
||||||
type="button"
|
<label className="text-sm font-medium text-slate-900">
|
||||||
onClick={() => router.push("/agents")}
|
Communication style
|
||||||
>
|
</label>
|
||||||
Back to agents
|
<Input
|
||||||
</Button>
|
value={identityProfile.communication_style}
|
||||||
</div>
|
onChange={(event) =>
|
||||||
|
setIdentityProfile((current) => ({
|
||||||
|
...current,
|
||||||
|
communication_style: event.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-900">
|
||||||
|
Soul template
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
value={soulTemplate}
|
||||||
|
onChange={(event) => setSoulTemplate(event.target.value)}
|
||||||
|
rows={10}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||||
|
Schedule & notifications
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 grid gap-6 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-900">
|
||||||
|
Interval
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={heartbeatEvery}
|
||||||
|
onChange={(event) => setHeartbeatEvery(event.target.value)}
|
||||||
|
placeholder="e.g. 10m"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
How often this agent runs HEARTBEAT.md (10m, 30m, 2h).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-900">
|
||||||
|
Target
|
||||||
|
</label>
|
||||||
|
<SearchableSelect
|
||||||
|
ariaLabel="Select heartbeat target"
|
||||||
|
value={heartbeatTarget}
|
||||||
|
onValueChange={setHeartbeatTarget}
|
||||||
|
options={HEARTBEAT_TARGET_OPTIONS}
|
||||||
|
placeholder="Select target"
|
||||||
|
searchPlaceholder="Search targets..."
|
||||||
|
emptyMessage="No matching targets."
|
||||||
|
triggerClassName="w-full h-11 rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
|
||||||
|
contentClassName="rounded-xl border border-slate-200 shadow-lg"
|
||||||
|
itemClassName="px-4 py-3 text-sm text-slate-700 data-[selected=true]:bg-slate-50 data-[selected=true]:text-slate-900"
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{errorMessage ? (
|
||||||
|
<div className="rounded-lg border border-slate-200 bg-white p-3 text-sm text-slate-600 shadow-sm">
|
||||||
|
{errorMessage}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Button type="submit" disabled={isLoading}>
|
||||||
|
{isLoading ? "Creating…" : "Create agent"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
type="button"
|
||||||
|
onClick={() => router.push("/agents")}
|
||||||
|
>
|
||||||
|
Back to agents
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</DashboardPageLayout>
|
</DashboardPageLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -21,7 +21,10 @@ import { StatusPill } from "@/components/atoms/StatusPill";
|
|||||||
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
|
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
|
||||||
import { Button, buttonVariants } from "@/components/ui/button";
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog";
|
import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog";
|
||||||
import { TableEmptyStateRow, TableLoadingRow } from "@/components/ui/table-state";
|
import {
|
||||||
|
TableEmptyStateRow,
|
||||||
|
TableLoadingRow,
|
||||||
|
} from "@/components/ui/table-state";
|
||||||
|
|
||||||
import { ApiError } from "@/api/mutator";
|
import { ApiError } from "@/api/mutator";
|
||||||
import {
|
import {
|
||||||
@@ -257,7 +260,9 @@ export default function AgentsPage() {
|
|||||||
description={`${agents.length} agent${agents.length === 1 ? "" : "s"} total.`}
|
description={`${agents.length} agent${agents.length === 1 ? "" : "s"} total.`}
|
||||||
headerActions={
|
headerActions={
|
||||||
agents.length > 0 ? (
|
agents.length > 0 ? (
|
||||||
<Button onClick={() => router.push("/agents/new")}>New agent</Button>
|
<Button onClick={() => router.push("/agents/new")}>
|
||||||
|
New agent
|
||||||
|
</Button>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
@@ -330,7 +335,9 @@ export default function AgentsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{agentsQuery.error ? (
|
{agentsQuery.error ? (
|
||||||
<p className="mt-4 text-sm text-red-500">{agentsQuery.error.message}</p>
|
<p className="mt-4 text-sm text-red-500">
|
||||||
|
{agentsQuery.error.message}
|
||||||
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</DashboardPageLayout>
|
</DashboardPageLayout>
|
||||||
|
|
||||||
@@ -345,8 +352,7 @@ export default function AgentsPage() {
|
|||||||
title="Delete agent"
|
title="Delete agent"
|
||||||
description={
|
description={
|
||||||
<>
|
<>
|
||||||
This will remove {deleteTarget?.name}. This action cannot be
|
This will remove {deleteTarget?.name}. This action cannot be undone.
|
||||||
undone.
|
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
errorMessage={deleteMutation.error?.message}
|
errorMessage={deleteMutation.error?.message}
|
||||||
|
|||||||
@@ -290,166 +290,164 @@ export default function EditBoardGroupPage() {
|
|||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
|
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
|
||||||
>
|
>
|
||||||
{assignFailedCount && Number.isFinite(assignFailedCount) ? (
|
{assignFailedCount && Number.isFinite(assignFailedCount) ? (
|
||||||
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900 shadow-sm">
|
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900 shadow-sm">
|
||||||
Group was created, but {assignFailedCount} board assignment
|
Group was created, but {assignFailedCount} board assignment
|
||||||
{assignFailedCount === 1 ? "" : "s"} failed. You can retry
|
{assignFailedCount === 1 ? "" : "s"} failed. You can retry below.
|
||||||
below.
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
) : null}
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
<div className="space-y-2">
|
||||||
<div className="space-y-2">
|
<label className="text-sm font-medium text-slate-900">
|
||||||
<label className="text-sm font-medium text-slate-900">
|
Group name <span className="text-red-500">*</span>
|
||||||
Group name <span className="text-red-500">*</span>
|
</label>
|
||||||
</label>
|
<Input
|
||||||
<Input
|
value={resolvedName}
|
||||||
value={resolvedName}
|
onChange={(event) => setName(event.target.value)}
|
||||||
onChange={(event) => setName(event.target.value)}
|
placeholder="Group name"
|
||||||
placeholder="Group name"
|
disabled={isLoading || !baseGroup}
|
||||||
disabled={isLoading || !baseGroup}
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-slate-900">
|
||||||
|
Description
|
||||||
|
</label>
|
||||||
|
<Textarea
|
||||||
|
value={resolvedDescription}
|
||||||
|
onChange={(event) => setDescription(event.target.value)}
|
||||||
|
placeholder="What ties these boards together?"
|
||||||
|
className="min-h-[120px]"
|
||||||
|
disabled={isLoading || !baseGroup}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 border-t border-slate-100 pt-6">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-900">Boards</p>
|
||||||
|
<p className="mt-1 text-xs text-slate-500">
|
||||||
|
Assign boards to this group to share context across related
|
||||||
|
work.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-slate-500">
|
||||||
|
{selectedBoardIds.size} selected
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
value={boardSearch}
|
||||||
|
onChange={(event) => setBoardSearch(event.target.value)}
|
||||||
|
placeholder="Search boards..."
|
||||||
|
disabled={isLoading || !baseGroup}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="max-h-64 overflow-auto rounded-xl border border-slate-200 bg-slate-50/40">
|
||||||
|
{boardsLoading && boards.length === 0 ? (
|
||||||
|
<div className="px-4 py-6 text-sm text-slate-500">
|
||||||
|
Loading boards…
|
||||||
</div>
|
</div>
|
||||||
|
) : boardsError ? (
|
||||||
<div className="space-y-2">
|
<div className="px-4 py-6 text-sm text-rose-700">
|
||||||
<label className="text-sm font-medium text-slate-900">
|
{boardsError.message}
|
||||||
Description
|
|
||||||
</label>
|
|
||||||
<Textarea
|
|
||||||
value={resolvedDescription}
|
|
||||||
onChange={(event) => setDescription(event.target.value)}
|
|
||||||
placeholder="What ties these boards together?"
|
|
||||||
className="min-h-[120px]"
|
|
||||||
disabled={isLoading || !baseGroup}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : boards.length === 0 ? (
|
||||||
<div className="space-y-2 border-t border-slate-100 pt-6">
|
<div className="px-4 py-6 text-sm text-slate-500">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
No boards found.
|
||||||
<div>
|
|
||||||
<p className="text-sm font-medium text-slate-900">Boards</p>
|
|
||||||
<p className="mt-1 text-xs text-slate-500">
|
|
||||||
Assign boards to this group to share context across
|
|
||||||
related work.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-slate-500">
|
|
||||||
{selectedBoardIds.size} selected
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
value={boardSearch}
|
|
||||||
onChange={(event) => setBoardSearch(event.target.value)}
|
|
||||||
placeholder="Search boards..."
|
|
||||||
disabled={isLoading || !baseGroup}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="max-h-64 overflow-auto rounded-xl border border-slate-200 bg-slate-50/40">
|
|
||||||
{boardsLoading && boards.length === 0 ? (
|
|
||||||
<div className="px-4 py-6 text-sm text-slate-500">
|
|
||||||
Loading boards…
|
|
||||||
</div>
|
|
||||||
) : boardsError ? (
|
|
||||||
<div className="px-4 py-6 text-sm text-rose-700">
|
|
||||||
{boardsError.message}
|
|
||||||
</div>
|
|
||||||
) : boards.length === 0 ? (
|
|
||||||
<div className="px-4 py-6 text-sm text-slate-500">
|
|
||||||
No boards found.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ul className="divide-y divide-slate-200">
|
|
||||||
{boards
|
|
||||||
.filter((board) => {
|
|
||||||
const q = boardSearch.trim().toLowerCase();
|
|
||||||
if (!q) return true;
|
|
||||||
return (
|
|
||||||
board.name.toLowerCase().includes(q) ||
|
|
||||||
board.slug.toLowerCase().includes(q)
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.map((board) => {
|
|
||||||
const checked = selectedBoardIds.has(board.id);
|
|
||||||
const isInThisGroup =
|
|
||||||
board.board_group_id === groupId;
|
|
||||||
const isAlreadyGrouped =
|
|
||||||
Boolean(board.board_group_id) && !isInThisGroup;
|
|
||||||
return (
|
|
||||||
<li key={board.id} className="px-4 py-3">
|
|
||||||
<label className="flex cursor-pointer items-start gap-3">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="mt-1 h-4 w-4 rounded border-slate-300 text-blue-600"
|
|
||||||
checked={checked}
|
|
||||||
onChange={() => {
|
|
||||||
setSelectedBoardIds((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(board.id)) {
|
|
||||||
next.delete(board.id);
|
|
||||||
} else {
|
|
||||||
next.add(board.id);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
disabled={isLoading || !baseGroup}
|
|
||||||
/>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="truncate text-sm font-medium text-slate-900">
|
|
||||||
{board.name}
|
|
||||||
</p>
|
|
||||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-slate-500">
|
|
||||||
<span className="font-mono text-[11px] text-slate-400">
|
|
||||||
{board.id}
|
|
||||||
</span>
|
|
||||||
{isAlreadyGrouped ? (
|
|
||||||
<span className="rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-amber-900">
|
|
||||||
in another group
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{assignmentsError ? (
|
|
||||||
<p className="text-sm text-rose-700">{assignmentsError}</p>
|
|
||||||
) : null}
|
|
||||||
{assignmentsResult ? (
|
|
||||||
<p className="text-sm text-slate-700">
|
|
||||||
Updated {assignmentsResult.updated} board
|
|
||||||
{assignmentsResult.updated === 1 ? "" : "s"}, failed{" "}
|
|
||||||
{assignmentsResult.failed}.
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-slate-200">
|
||||||
|
{boards
|
||||||
|
.filter((board) => {
|
||||||
|
const q = boardSearch.trim().toLowerCase();
|
||||||
|
if (!q) return true;
|
||||||
|
return (
|
||||||
|
board.name.toLowerCase().includes(q) ||
|
||||||
|
board.slug.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.map((board) => {
|
||||||
|
const checked = selectedBoardIds.has(board.id);
|
||||||
|
const isInThisGroup = board.board_group_id === groupId;
|
||||||
|
const isAlreadyGrouped =
|
||||||
|
Boolean(board.board_group_id) && !isInThisGroup;
|
||||||
|
return (
|
||||||
|
<li key={board.id} className="px-4 py-3">
|
||||||
|
<label className="flex cursor-pointer items-start gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-1 h-4 w-4 rounded border-slate-300 text-blue-600"
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => {
|
||||||
|
setSelectedBoardIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(board.id)) {
|
||||||
|
next.delete(board.id);
|
||||||
|
} else {
|
||||||
|
next.add(board.id);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={isLoading || !baseGroup}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-medium text-slate-900">
|
||||||
|
{board.name}
|
||||||
|
</p>
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-slate-500">
|
||||||
|
<span className="font-mono text-[11px] text-slate-400">
|
||||||
|
{board.id}
|
||||||
|
</span>
|
||||||
|
{isAlreadyGrouped ? (
|
||||||
|
<span className="rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-amber-900">
|
||||||
|
in another group
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{errorMessage ? (
|
{assignmentsError ? (
|
||||||
<p className="text-sm text-red-500">{errorMessage}</p>
|
<p className="text-sm text-rose-700">{assignmentsError}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
{assignmentsResult ? (
|
||||||
|
<p className="text-sm text-slate-700">
|
||||||
|
Updated {assignmentsResult.updated} board
|
||||||
|
{assignmentsResult.updated === 1 ? "" : "s"}, failed{" "}
|
||||||
|
{assignmentsResult.failed}.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-3">
|
{errorMessage ? (
|
||||||
<Button
|
<p className="text-sm text-red-500">{errorMessage}</p>
|
||||||
type="button"
|
) : null}
|
||||||
variant="ghost"
|
|
||||||
onClick={() => router.push(`/board-groups/${groupId ?? ""}`)}
|
<div className="flex justify-end gap-3">
|
||||||
disabled={isLoading}
|
<Button
|
||||||
>
|
type="button"
|
||||||
Cancel
|
variant="ghost"
|
||||||
</Button>
|
onClick={() => router.push(`/board-groups/${groupId ?? ""}`)}
|
||||||
<Button
|
disabled={isLoading}
|
||||||
type="submit"
|
>
|
||||||
disabled={isLoading || !baseGroup || !isFormReady}
|
Cancel
|
||||||
>
|
</Button>
|
||||||
{isLoading ? "Saving…" : "Save changes"}
|
<Button
|
||||||
</Button>
|
type="submit"
|
||||||
</div>
|
disabled={isLoading || !baseGroup || !isFormReady}
|
||||||
|
>
|
||||||
|
{isLoading ? "Saving…" : "Save changes"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</DashboardPageLayout>
|
</DashboardPageLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -135,152 +135,148 @@ export default function NewBoardGroupPage() {
|
|||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
|
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
|
||||||
>
|
>
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-900">
|
<label className="text-sm font-medium text-slate-900">
|
||||||
Group name <span className="text-red-500">*</span>
|
Group name <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<Input
|
<Input
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(event) => setName(event.target.value)}
|
onChange={(event) => setName(event.target.value)}
|
||||||
placeholder="e.g. Release hardening"
|
placeholder="e.g. Release hardening"
|
||||||
disabled={isCreating}
|
disabled={isCreating}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-900">
|
<label className="text-sm font-medium text-slate-900">
|
||||||
Description
|
Description
|
||||||
</label>
|
</label>
|
||||||
<Textarea
|
<Textarea
|
||||||
value={description}
|
value={description}
|
||||||
onChange={(event) => setDescription(event.target.value)}
|
onChange={(event) => setDescription(event.target.value)}
|
||||||
placeholder="What ties these boards together? What should agents coordinate on?"
|
placeholder="What ties these boards together? What should agents coordinate on?"
|
||||||
className="min-h-[120px]"
|
className="min-h-[120px]"
|
||||||
disabled={isCreating}
|
disabled={isCreating}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<label className="text-sm font-medium text-slate-900">
|
<label className="text-sm font-medium text-slate-900">Boards</label>
|
||||||
Boards
|
<span className="text-xs text-slate-500">
|
||||||
</label>
|
{selectedBoardIds.size} selected
|
||||||
<span className="text-xs text-slate-500">
|
</span>
|
||||||
{selectedBoardIds.size} selected
|
</div>
|
||||||
</span>
|
<Input
|
||||||
</div>
|
value={boardSearch}
|
||||||
<Input
|
onChange={(event) => setBoardSearch(event.target.value)}
|
||||||
value={boardSearch}
|
placeholder="Search boards..."
|
||||||
onChange={(event) => setBoardSearch(event.target.value)}
|
disabled={isCreating}
|
||||||
placeholder="Search boards..."
|
/>
|
||||||
disabled={isCreating}
|
<div className="max-h-64 overflow-auto rounded-xl border border-slate-200 bg-slate-50/40">
|
||||||
/>
|
{boardsQuery.isLoading ? (
|
||||||
<div className="max-h-64 overflow-auto rounded-xl border border-slate-200 bg-slate-50/40">
|
<div className="px-4 py-6 text-sm text-slate-500">
|
||||||
{boardsQuery.isLoading ? (
|
Loading boards…
|
||||||
<div className="px-4 py-6 text-sm text-slate-500">
|
|
||||||
Loading boards…
|
|
||||||
</div>
|
|
||||||
) : boardsQuery.error ? (
|
|
||||||
<div className="px-4 py-6 text-sm text-rose-700">
|
|
||||||
{boardsQuery.error.message}
|
|
||||||
</div>
|
|
||||||
) : boards.length === 0 ? (
|
|
||||||
<div className="px-4 py-6 text-sm text-slate-500">
|
|
||||||
No boards found.
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ul className="divide-y divide-slate-200">
|
|
||||||
{boards
|
|
||||||
.filter((board) => {
|
|
||||||
const q = boardSearch.trim().toLowerCase();
|
|
||||||
if (!q) return true;
|
|
||||||
return (
|
|
||||||
board.name.toLowerCase().includes(q) ||
|
|
||||||
board.slug.toLowerCase().includes(q)
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.map((board) => {
|
|
||||||
const checked = selectedBoardIds.has(board.id);
|
|
||||||
const isAlreadyGrouped = Boolean(
|
|
||||||
board.board_group_id,
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<li key={board.id} className="px-4 py-3">
|
|
||||||
<label className="flex cursor-pointer items-start gap-3">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="mt-1 h-4 w-4 rounded border-slate-300 text-blue-600"
|
|
||||||
checked={checked}
|
|
||||||
onChange={() => {
|
|
||||||
setSelectedBoardIds((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(board.id)) {
|
|
||||||
next.delete(board.id);
|
|
||||||
} else {
|
|
||||||
next.add(board.id);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
disabled={isCreating}
|
|
||||||
/>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<p className="truncate text-sm font-medium text-slate-900">
|
|
||||||
{board.name}
|
|
||||||
</p>
|
|
||||||
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-slate-500">
|
|
||||||
<span className="font-mono text-[11px] text-slate-400">
|
|
||||||
{board.id}
|
|
||||||
</span>
|
|
||||||
{isAlreadyGrouped ? (
|
|
||||||
<span className="rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-amber-900">
|
|
||||||
currently grouped
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-slate-500">
|
|
||||||
Optional. Selected boards will be assigned to this group after
|
|
||||||
creation. You can change membership later in group edit or
|
|
||||||
board settings.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : boardsQuery.error ? (
|
||||||
{error ? <p className="text-sm text-red-500">{error}</p> : null}
|
<div className="px-4 py-6 text-sm text-rose-700">
|
||||||
|
{boardsQuery.error.message}
|
||||||
<div className="flex justify-end gap-3">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
onClick={() => router.push("/board-groups")}
|
|
||||||
disabled={isCreating}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={isCreating || !isFormReady}>
|
|
||||||
{isCreating ? "Creating…" : "Create group"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : boards.length === 0 ? (
|
||||||
<div className="border-t border-slate-100 pt-4 text-xs text-slate-500">
|
<div className="px-4 py-6 text-sm text-slate-500">
|
||||||
Want to assign boards later? Update each board in{" "}
|
No boards found.
|
||||||
<Link
|
|
||||||
href="/boards"
|
|
||||||
className="font-medium text-blue-600 hover:text-blue-700"
|
|
||||||
>
|
|
||||||
Boards
|
|
||||||
</Link>{" "}
|
|
||||||
and pick this group.
|
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<ul className="divide-y divide-slate-200">
|
||||||
|
{boards
|
||||||
|
.filter((board) => {
|
||||||
|
const q = boardSearch.trim().toLowerCase();
|
||||||
|
if (!q) return true;
|
||||||
|
return (
|
||||||
|
board.name.toLowerCase().includes(q) ||
|
||||||
|
board.slug.toLowerCase().includes(q)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.map((board) => {
|
||||||
|
const checked = selectedBoardIds.has(board.id);
|
||||||
|
const isAlreadyGrouped = Boolean(board.board_group_id);
|
||||||
|
return (
|
||||||
|
<li key={board.id} className="px-4 py-3">
|
||||||
|
<label className="flex cursor-pointer items-start gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="mt-1 h-4 w-4 rounded border-slate-300 text-blue-600"
|
||||||
|
checked={checked}
|
||||||
|
onChange={() => {
|
||||||
|
setSelectedBoardIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(board.id)) {
|
||||||
|
next.delete(board.id);
|
||||||
|
} else {
|
||||||
|
next.add(board.id);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
disabled={isCreating}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-medium text-slate-900">
|
||||||
|
{board.name}
|
||||||
|
</p>
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs text-slate-500">
|
||||||
|
<span className="font-mono text-[11px] text-slate-400">
|
||||||
|
{board.id}
|
||||||
|
</span>
|
||||||
|
{isAlreadyGrouped ? (
|
||||||
|
<span className="rounded-full border border-amber-200 bg-amber-50 px-2 py-0.5 text-amber-900">
|
||||||
|
currently grouped
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Optional. Selected boards will be assigned to this group after
|
||||||
|
creation. You can change membership later in group edit or board
|
||||||
|
settings.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error ? <p className="text-sm text-red-500">{error}</p> : null}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => router.push("/board-groups")}
|
||||||
|
disabled={isCreating}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={isCreating || !isFormReady}>
|
||||||
|
{isCreating ? "Creating…" : "Create group"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-slate-100 pt-4 text-xs text-slate-500">
|
||||||
|
Want to assign boards later? Update each board in{" "}
|
||||||
|
<Link
|
||||||
|
href="/boards"
|
||||||
|
className="font-medium text-blue-600 hover:text-blue-700"
|
||||||
|
>
|
||||||
|
Boards
|
||||||
|
</Link>{" "}
|
||||||
|
and pick this group.
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</DashboardPageLayout>
|
</DashboardPageLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -26,7 +26,10 @@ import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout"
|
|||||||
import { Button, buttonVariants } from "@/components/ui/button";
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog";
|
import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog";
|
||||||
import { formatTimestamp } from "@/lib/formatters";
|
import { formatTimestamp } from "@/lib/formatters";
|
||||||
import { TableEmptyStateRow, TableLoadingRow } from "@/components/ui/table-state";
|
import {
|
||||||
|
TableEmptyStateRow,
|
||||||
|
TableLoadingRow,
|
||||||
|
} from "@/components/ui/table-state";
|
||||||
|
|
||||||
export default function BoardGroupsPage() {
|
export default function BoardGroupsPage() {
|
||||||
const { isSignedIn } = useAuth();
|
const { isSignedIn } = useAuth();
|
||||||
@@ -258,7 +261,9 @@ export default function BoardGroupsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{groupsQuery.error ? (
|
{groupsQuery.error ? (
|
||||||
<p className="mt-4 text-sm text-red-500">{groupsQuery.error.message}</p>
|
<p className="mt-4 text-sm text-red-500">
|
||||||
|
{groupsQuery.error.message}
|
||||||
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</DashboardPageLayout>
|
</DashboardPageLayout>
|
||||||
<ConfirmActionDialog
|
<ConfirmActionDialog
|
||||||
|
|||||||
@@ -307,178 +307,169 @@ export default function EditBoardPage() {
|
|||||||
onSubmit={handleSubmit}
|
onSubmit={handleSubmit}
|
||||||
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
|
className="space-y-6 rounded-xl border border-slate-200 bg-white p-6 shadow-sm"
|
||||||
>
|
>
|
||||||
{resolvedBoardType !== "general" &&
|
{resolvedBoardType !== "general" &&
|
||||||
baseBoard &&
|
baseBoard &&
|
||||||
!(baseBoard.goal_confirmed ?? false) ? (
|
!(baseBoard.goal_confirmed ?? false) ? (
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3">
|
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-amber-200 bg-amber-50 px-4 py-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-sm font-semibold text-amber-900">
|
<p className="text-sm font-semibold text-amber-900">
|
||||||
Goal needs confirmation
|
Goal needs confirmation
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-xs text-amber-800/80">
|
<p className="mt-1 text-xs text-amber-800/80">
|
||||||
Start onboarding to draft an objective and success
|
Start onboarding to draft an objective and success metrics.
|
||||||
metrics.
|
</p>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
<Button
|
||||||
<Button
|
type="button"
|
||||||
type="button"
|
variant="secondary"
|
||||||
variant="secondary"
|
onClick={() => setIsOnboardingOpen(true)}
|
||||||
onClick={() => setIsOnboardingOpen(true)}
|
disabled={isLoading || !baseBoard}
|
||||||
disabled={isLoading || !baseBoard}
|
>
|
||||||
>
|
Start onboarding
|
||||||
Start onboarding
|
</Button>
|
||||||
</Button>
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
) : null}
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
<div className="space-y-2">
|
||||||
<div className="space-y-2">
|
<label className="text-sm font-medium text-slate-900">
|
||||||
<label className="text-sm font-medium text-slate-900">
|
Board name <span className="text-red-500">*</span>
|
||||||
Board name <span className="text-red-500">*</span>
|
</label>
|
||||||
</label>
|
<Input
|
||||||
<Input
|
value={resolvedName}
|
||||||
value={resolvedName}
|
onChange={(event) => setName(event.target.value)}
|
||||||
onChange={(event) => setName(event.target.value)}
|
placeholder="Board name"
|
||||||
placeholder="Board name"
|
disabled={isLoading || !baseBoard}
|
||||||
disabled={isLoading || !baseBoard}
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
<div className="space-y-2">
|
||||||
<div className="space-y-2">
|
<label className="text-sm font-medium text-slate-900">
|
||||||
<label className="text-sm font-medium text-slate-900">
|
Gateway <span className="text-red-500">*</span>
|
||||||
Gateway <span className="text-red-500">*</span>
|
</label>
|
||||||
</label>
|
<SearchableSelect
|
||||||
<SearchableSelect
|
ariaLabel="Select gateway"
|
||||||
ariaLabel="Select gateway"
|
value={displayGatewayId}
|
||||||
value={displayGatewayId}
|
onValueChange={setGatewayId}
|
||||||
onValueChange={setGatewayId}
|
options={gatewayOptions}
|
||||||
options={gatewayOptions}
|
placeholder="Select gateway"
|
||||||
placeholder="Select gateway"
|
searchPlaceholder="Search gateways..."
|
||||||
searchPlaceholder="Search gateways..."
|
emptyMessage="No gateways found."
|
||||||
emptyMessage="No gateways found."
|
triggerClassName="w-full h-11 rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
|
||||||
triggerClassName="w-full h-11 rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
|
contentClassName="rounded-xl border border-slate-200 shadow-lg"
|
||||||
contentClassName="rounded-xl border border-slate-200 shadow-lg"
|
itemClassName="px-4 py-3 text-sm text-slate-700 data-[selected=true]:bg-slate-50 data-[selected=true]:text-slate-900"
|
||||||
itemClassName="px-4 py-3 text-sm text-slate-700 data-[selected=true]:bg-slate-50 data-[selected=true]:text-slate-900"
|
/>
|
||||||
/>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-6 md:grid-cols-2">
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-900">
|
<label className="text-sm font-medium text-slate-900">
|
||||||
Board type
|
Board type
|
||||||
</label>
|
</label>
|
||||||
<Select
|
<Select value={resolvedBoardType} onValueChange={setBoardType}>
|
||||||
value={resolvedBoardType}
|
<SelectTrigger>
|
||||||
onValueChange={setBoardType}
|
<SelectValue placeholder="Select board type" />
|
||||||
>
|
</SelectTrigger>
|
||||||
<SelectTrigger>
|
<SelectContent>
|
||||||
<SelectValue placeholder="Select board type" />
|
<SelectItem value="goal">Goal</SelectItem>
|
||||||
</SelectTrigger>
|
<SelectItem value="general">General</SelectItem>
|
||||||
<SelectContent>
|
</SelectContent>
|
||||||
<SelectItem value="goal">Goal</SelectItem>
|
</Select>
|
||||||
<SelectItem value="general">General</SelectItem>
|
</div>
|
||||||
</SelectContent>
|
<div className="space-y-2">
|
||||||
</Select>
|
<label className="text-sm font-medium text-slate-900">
|
||||||
</div>
|
Board group
|
||||||
<div className="space-y-2">
|
</label>
|
||||||
<label className="text-sm font-medium text-slate-900">
|
<SearchableSelect
|
||||||
Board group
|
ariaLabel="Select board group"
|
||||||
</label>
|
value={resolvedBoardGroupId}
|
||||||
<SearchableSelect
|
onValueChange={setBoardGroupId}
|
||||||
ariaLabel="Select board group"
|
options={groupOptions}
|
||||||
value={resolvedBoardGroupId}
|
placeholder="No group"
|
||||||
onValueChange={setBoardGroupId}
|
searchPlaceholder="Search groups..."
|
||||||
options={groupOptions}
|
emptyMessage="No groups found."
|
||||||
placeholder="No group"
|
triggerClassName="w-full h-11 rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
|
||||||
searchPlaceholder="Search groups..."
|
contentClassName="rounded-xl border border-slate-200 shadow-lg"
|
||||||
emptyMessage="No groups found."
|
itemClassName="px-4 py-3 text-sm text-slate-700 data-[selected=true]:bg-slate-50 data-[selected=true]:text-slate-900"
|
||||||
triggerClassName="w-full h-11 rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-900 shadow-sm focus:border-blue-500 focus:ring-2 focus:ring-blue-200"
|
disabled={isLoading}
|
||||||
contentClassName="rounded-xl border border-slate-200 shadow-lg"
|
/>
|
||||||
itemClassName="px-4 py-3 text-sm text-slate-700 data-[selected=true]:bg-slate-50 data-[selected=true]:text-slate-900"
|
<p className="text-xs text-slate-500">
|
||||||
disabled={isLoading}
|
Boards in the same group can share cross-board context for
|
||||||
/>
|
agents.
|
||||||
<p className="text-xs text-slate-500">
|
</p>
|
||||||
Boards in the same group can share cross-board context
|
</div>
|
||||||
for agents.
|
<div className="space-y-2">
|
||||||
</p>
|
<label className="text-sm font-medium text-slate-900">
|
||||||
</div>
|
Target date
|
||||||
<div className="space-y-2">
|
</label>
|
||||||
<label className="text-sm font-medium text-slate-900">
|
<Input
|
||||||
Target date
|
type="date"
|
||||||
</label>
|
value={resolvedTargetDate}
|
||||||
<Input
|
onChange={(event) => setTargetDate(event.target.value)}
|
||||||
type="date"
|
disabled={isLoading}
|
||||||
value={resolvedTargetDate}
|
/>
|
||||||
onChange={(event) =>
|
</div>
|
||||||
setTargetDate(event.target.value)
|
</div>
|
||||||
}
|
|
||||||
disabled={isLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-900">
|
<label className="text-sm font-medium text-slate-900">
|
||||||
Objective
|
Objective
|
||||||
</label>
|
</label>
|
||||||
<Textarea
|
<Textarea
|
||||||
value={resolvedObjective}
|
value={resolvedObjective}
|
||||||
onChange={(event) => setObjective(event.target.value)}
|
onChange={(event) => setObjective(event.target.value)}
|
||||||
placeholder="What should this board achieve?"
|
placeholder="What should this board achieve?"
|
||||||
className="min-h-[120px]"
|
className="min-h-[120px]"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-slate-900">
|
<label className="text-sm font-medium text-slate-900">
|
||||||
Success metrics (JSON)
|
Success metrics (JSON)
|
||||||
</label>
|
</label>
|
||||||
<Textarea
|
<Textarea
|
||||||
value={resolvedSuccessMetrics}
|
value={resolvedSuccessMetrics}
|
||||||
onChange={(event) =>
|
onChange={(event) => setSuccessMetrics(event.target.value)}
|
||||||
setSuccessMetrics(event.target.value)
|
placeholder='e.g. { "target": "Launch by week 2" }'
|
||||||
}
|
className="min-h-[140px] font-mono text-xs"
|
||||||
placeholder='e.g. { "target": "Launch by week 2" }'
|
disabled={isLoading}
|
||||||
className="min-h-[140px] font-mono text-xs"
|
/>
|
||||||
disabled={isLoading}
|
<p className="text-xs text-slate-500">
|
||||||
/>
|
Add key outcomes so the lead agent can measure progress.
|
||||||
<p className="text-xs text-slate-500">
|
</p>
|
||||||
Add key outcomes so the lead agent can measure progress.
|
{metricsError ? (
|
||||||
</p>
|
<p className="text-xs text-red-500">{metricsError}</p>
|
||||||
{metricsError ? (
|
) : null}
|
||||||
<p className="text-xs text-red-500">{metricsError}</p>
|
</div>
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{gateways.length === 0 ? (
|
{gateways.length === 0 ? (
|
||||||
<div className="rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
|
<div className="rounded-lg border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
|
||||||
<p>
|
<p>
|
||||||
No gateways available. Create one in Gateways to
|
No gateways available. Create one in Gateways to continue.
|
||||||
continue.
|
</p>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
) : null}
|
|
||||||
|
|
||||||
{errorMessage ? (
|
{errorMessage ? (
|
||||||
<p className="text-sm text-red-500">{errorMessage}</p>
|
<p className="text-sm text-red-500">{errorMessage}</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="flex justify-end gap-3">
|
<div className="flex justify-end gap-3">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => router.push(`/boards/${boardId}`)}
|
onClick={() => router.push(`/boards/${boardId}`)}
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={isLoading || !baseBoard || !isFormReady}
|
disabled={isLoading || !baseBoard || !isFormReady}
|
||||||
>
|
>
|
||||||
{isLoading ? "Saving…" : "Save changes"}
|
{isLoading ? "Saving…" : "Save changes"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</DashboardPageLayout>
|
</DashboardPageLayout>
|
||||||
|
|||||||
@@ -225,7 +225,9 @@ export default function NewBoardPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{errorMessage ? <p className="text-sm text-red-500">{errorMessage}</p> : null}
|
{errorMessage ? (
|
||||||
|
<p className="text-sm text-red-500">{errorMessage}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="flex justify-end gap-3">
|
<div className="flex justify-end gap-3">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -31,7 +31,10 @@ import type { BoardGroupRead, BoardRead } from "@/api/generated/model";
|
|||||||
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
|
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
|
||||||
import { Button, buttonVariants } from "@/components/ui/button";
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog";
|
import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog";
|
||||||
import { TableEmptyStateRow, TableLoadingRow } from "@/components/ui/table-state";
|
import {
|
||||||
|
TableEmptyStateRow,
|
||||||
|
TableLoadingRow,
|
||||||
|
} from "@/components/ui/table-state";
|
||||||
|
|
||||||
const compactId = (value: string) =>
|
const compactId = (value: string) =>
|
||||||
value.length > 8 ? `${value.slice(0, 8)}…` : value;
|
value.length > 8 ? `${value.slice(0, 8)}…` : value;
|
||||||
@@ -312,7 +315,9 @@ export default function BoardsPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{boardsQuery.error ? (
|
{boardsQuery.error ? (
|
||||||
<p className="mt-4 text-sm text-red-500">{boardsQuery.error.message}</p>
|
<p className="mt-4 text-sm text-red-500">
|
||||||
|
{boardsQuery.error.message}
|
||||||
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</DashboardPageLayout>
|
</DashboardPageLayout>
|
||||||
<ConfirmActionDialog
|
<ConfirmActionDialog
|
||||||
@@ -326,8 +331,7 @@ export default function BoardsPage() {
|
|||||||
title="Delete board"
|
title="Delete board"
|
||||||
description={
|
description={
|
||||||
<>
|
<>
|
||||||
This will remove {deleteTarget?.name}. This action cannot be
|
This will remove {deleteTarget?.name}. This action cannot be undone.
|
||||||
undone.
|
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
errorMessage={deleteMutation.error?.message}
|
errorMessage={deleteMutation.error?.message}
|
||||||
|
|||||||
@@ -131,154 +131,146 @@ export default function GatewayDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : gateway ? (
|
) : gateway ? (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="grid gap-6 lg:grid-cols-2">
|
<div className="grid gap-6 lg:grid-cols-2">
|
||||||
<div className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
<div className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||||
Connection
|
Connection
|
||||||
</p>
|
</p>
|
||||||
<div className="flex items-center gap-2 text-xs text-slate-500">
|
<div className="flex items-center gap-2 text-xs text-slate-500">
|
||||||
<span
|
<span
|
||||||
className={`h-2 w-2 rounded-full ${
|
className={`h-2 w-2 rounded-full ${
|
||||||
statusQuery.isLoading
|
statusQuery.isLoading
|
||||||
? "bg-slate-300"
|
? "bg-slate-300"
|
||||||
: isConnected
|
: isConnected
|
||||||
? "bg-emerald-500"
|
? "bg-emerald-500"
|
||||||
: "bg-rose-500"
|
: "bg-rose-500"
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
<span>
|
<span>
|
||||||
{statusQuery.isLoading
|
{statusQuery.isLoading
|
||||||
? "Checking"
|
? "Checking"
|
||||||
: isConnected
|
: isConnected
|
||||||
? "Online"
|
? "Online"
|
||||||
: "Offline"}
|
: "Offline"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 space-y-3 text-sm text-slate-700">
|
<div className="mt-4 space-y-3 text-sm text-slate-700">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs uppercase text-slate-400">
|
<p className="text-xs uppercase text-slate-400">
|
||||||
Gateway URL
|
Gateway URL
|
||||||
</p>
|
</p>
|
||||||
<p className="mt-1 text-sm font-medium text-slate-900">
|
<p className="mt-1 text-sm font-medium text-slate-900">
|
||||||
{gateway.url}
|
{gateway.url}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs uppercase text-slate-400">
|
<p className="text-xs uppercase text-slate-400">Token</p>
|
||||||
Token
|
<p className="mt-1 text-sm font-medium text-slate-900">
|
||||||
</p>
|
{maskToken(gateway.token)}
|
||||||
<p className="mt-1 text-sm font-medium text-slate-900">
|
</p>
|
||||||
{maskToken(gateway.token)}
|
</div>
|
||||||
</p>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
<div className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||||
Runtime
|
Runtime
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 space-y-3 text-sm text-slate-700">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase text-slate-400">
|
||||||
|
Main session key
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm font-medium text-slate-900">
|
||||||
|
{gateway.main_session_key}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase text-slate-400">
|
||||||
|
Workspace root
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm font-medium text-slate-900">
|
||||||
|
{gateway.workspace_root}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase text-slate-400">Created</p>
|
||||||
|
<p className="mt-1 text-sm font-medium text-slate-900">
|
||||||
|
{formatTimestamp(gateway.created_at)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs uppercase text-slate-400">Updated</p>
|
||||||
|
<p className="mt-1 text-sm font-medium text-slate-900">
|
||||||
|
{formatTimestamp(gateway.updated_at)}
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-4 space-y-3 text-sm text-slate-700">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs uppercase text-slate-400">
|
|
||||||
Main session key
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-sm font-medium text-slate-900">
|
|
||||||
{gateway.main_session_key}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs uppercase text-slate-400">
|
|
||||||
Workspace root
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-sm font-medium text-slate-900">
|
|
||||||
{gateway.workspace_root}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs uppercase text-slate-400">
|
|
||||||
Created
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-sm font-medium text-slate-900">
|
|
||||||
{formatTimestamp(gateway.created_at)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<p className="text-xs uppercase text-slate-400">
|
|
||||||
Updated
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-sm font-medium text-slate-900">
|
|
||||||
{formatTimestamp(gateway.updated_at)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
<div className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">
|
||||||
Agents
|
Agents
|
||||||
</p>
|
</p>
|
||||||
{agentsQuery.isLoading ? (
|
{agentsQuery.isLoading ? (
|
||||||
<span className="text-xs text-slate-500">Loading…</span>
|
<span className="text-xs text-slate-500">Loading…</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-slate-500">
|
<span className="text-xs text-slate-500">
|
||||||
{agents.length} total
|
{agents.length} total
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4 overflow-x-auto">
|
<div className="mt-4 overflow-x-auto">
|
||||||
<table className="w-full text-left text-sm">
|
<table className="w-full text-left text-sm">
|
||||||
<thead className="bg-slate-50 text-xs uppercase tracking-wide text-slate-500">
|
<thead className="bg-slate-50 text-xs uppercase tracking-wide text-slate-500">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3">Agent</th>
|
<th className="px-4 py-3">Agent</th>
|
||||||
<th className="px-4 py-3">Status</th>
|
<th className="px-4 py-3">Status</th>
|
||||||
<th className="px-4 py-3">Last seen</th>
|
<th className="px-4 py-3">Last seen</th>
|
||||||
<th className="px-4 py-3">Updated</th>
|
<th className="px-4 py-3">Updated</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-slate-100">
|
<tbody className="divide-y divide-slate-100">
|
||||||
{agents.length === 0 && !agentsQuery.isLoading ? (
|
{agents.length === 0 && !agentsQuery.isLoading ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td
|
<td
|
||||||
colSpan={4}
|
colSpan={4}
|
||||||
className="px-4 py-6 text-center text-xs text-slate-500"
|
className="px-4 py-6 text-center text-xs text-slate-500"
|
||||||
>
|
>
|
||||||
No agents assigned to this gateway.
|
No agents assigned to this gateway.
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
agents.map((agent) => (
|
agents.map((agent) => (
|
||||||
<tr key={agent.id} className="hover:bg-slate-50">
|
<tr key={agent.id} className="hover:bg-slate-50">
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<p className="text-sm font-medium text-slate-900">
|
<p className="text-sm font-medium text-slate-900">
|
||||||
{agent.name}
|
{agent.name}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-slate-500">
|
<p className="text-xs text-slate-500">{agent.id}</p>
|
||||||
{agent.id}
|
</td>
|
||||||
</p>
|
<td className="px-4 py-3 text-sm text-slate-700">
|
||||||
</td>
|
{agent.status}
|
||||||
<td className="px-4 py-3 text-sm text-slate-700">
|
</td>
|
||||||
{agent.status}
|
<td className="px-4 py-3 text-xs text-slate-500">
|
||||||
</td>
|
{formatTimestamp(agent.last_seen_at ?? null)}
|
||||||
<td className="px-4 py-3 text-xs text-slate-500">
|
</td>
|
||||||
{formatTimestamp(agent.last_seen_at ?? null)}
|
<td className="px-4 py-3 text-xs text-slate-500">
|
||||||
</td>
|
{formatTimestamp(agent.updated_at)}
|
||||||
<td className="px-4 py-3 text-xs text-slate-500">
|
</td>
|
||||||
{formatTimestamp(agent.updated_at)}
|
</tr>
|
||||||
</td>
|
))
|
||||||
</tr>
|
)}
|
||||||
))
|
</tbody>
|
||||||
)}
|
</table>
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</DashboardPageLayout>
|
</DashboardPageLayout>
|
||||||
|
|||||||
@@ -19,7 +19,10 @@ import { useQueryClient } from "@tanstack/react-query";
|
|||||||
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
|
import { DashboardPageLayout } from "@/components/templates/DashboardPageLayout";
|
||||||
import { Button, buttonVariants } from "@/components/ui/button";
|
import { Button, buttonVariants } from "@/components/ui/button";
|
||||||
import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog";
|
import { ConfirmActionDialog } from "@/components/ui/confirm-action-dialog";
|
||||||
import { TableEmptyStateRow, TableLoadingRow } from "@/components/ui/table-state";
|
import {
|
||||||
|
TableEmptyStateRow,
|
||||||
|
TableLoadingRow,
|
||||||
|
} from "@/components/ui/table-state";
|
||||||
|
|
||||||
import { ApiError } from "@/api/mutator";
|
import { ApiError } from "@/api/mutator";
|
||||||
import {
|
import {
|
||||||
@@ -196,95 +199,104 @@ export default function GatewaysPage() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DashboardPageLayout
|
<DashboardPageLayout
|
||||||
signedOut={{
|
signedOut={{
|
||||||
message: "Sign in to view gateways.",
|
message: "Sign in to view gateways.",
|
||||||
forceRedirectUrl: "/gateways",
|
forceRedirectUrl: "/gateways",
|
||||||
}}
|
}}
|
||||||
title="Gateways"
|
title="Gateways"
|
||||||
description="Manage OpenClaw gateway connections used by boards"
|
description="Manage OpenClaw gateway connections used by boards"
|
||||||
headerActions={
|
headerActions={
|
||||||
isAdmin && gateways.length > 0 ? (
|
isAdmin && gateways.length > 0 ? (
|
||||||
<Link
|
<Link
|
||||||
href="/gateways/new"
|
href="/gateways/new"
|
||||||
className={buttonVariants({
|
className={buttonVariants({
|
||||||
size: "md",
|
size: "md",
|
||||||
variant: "primary",
|
variant: "primary",
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
Create gateway
|
Create gateway
|
||||||
</Link>
|
</Link>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
isAdmin={isAdmin}
|
isAdmin={isAdmin}
|
||||||
adminOnlyMessage="Only organization owners and admins can access gateways."
|
adminOnlyMessage="Only organization owners and admins can access gateways."
|
||||||
stickyHeader
|
stickyHeader
|
||||||
>
|
>
|
||||||
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-left text-sm">
|
<table className="w-full text-left text-sm">
|
||||||
<thead className="sticky top-0 z-10 bg-slate-50 text-xs font-semibold uppercase tracking-wider text-slate-500">
|
<thead className="sticky top-0 z-10 bg-slate-50 text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||||
{table.getHeaderGroups().map((headerGroup) => (
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
<tr key={headerGroup.id}>
|
<tr key={headerGroup.id}>
|
||||||
{headerGroup.headers.map((header) => (
|
{headerGroup.headers.map((header) => (
|
||||||
<th key={header.id} className="px-6 py-3">
|
<th key={header.id} className="px-6 py-3">
|
||||||
{header.isPlaceholder
|
{header.isPlaceholder
|
||||||
? null
|
? null
|
||||||
: flexRender(
|
: flexRender(
|
||||||
header.column.columnDef.header,
|
header.column.columnDef.header,
|
||||||
header.getContext(),
|
header.getContext(),
|
||||||
)}
|
)}
|
||||||
</th>
|
</th>
|
||||||
))}
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-slate-100">
|
|
||||||
{gatewaysQuery.isLoading ? (
|
|
||||||
<TableLoadingRow colSpan={columns.length} />
|
|
||||||
) : table.getRowModel().rows.length ? (
|
|
||||||
table.getRowModel().rows.map((row) => (
|
|
||||||
<tr key={row.id} className="hover:bg-slate-50">
|
|
||||||
{row.getVisibleCells().map((cell) => (
|
|
||||||
<td key={cell.id} className="px-6 py-4">
|
|
||||||
{flexRender(
|
|
||||||
cell.column.columnDef.cell,
|
|
||||||
cell.getContext(),
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
))}
|
))}
|
||||||
</tr>
|
</tr>
|
||||||
))
|
))}
|
||||||
) : (
|
</thead>
|
||||||
<TableEmptyStateRow
|
<tbody className="divide-y divide-slate-100">
|
||||||
colSpan={columns.length}
|
{gatewaysQuery.isLoading ? (
|
||||||
icon={
|
<TableLoadingRow colSpan={columns.length} />
|
||||||
<svg
|
) : table.getRowModel().rows.length ? (
|
||||||
className="h-16 w-16 text-slate-300"
|
table.getRowModel().rows.map((row) => (
|
||||||
viewBox="0 0 24 24"
|
<tr key={row.id} className="hover:bg-slate-50">
|
||||||
fill="none"
|
{row.getVisibleCells().map((cell) => (
|
||||||
stroke="currentColor"
|
<td key={cell.id} className="px-6 py-4">
|
||||||
strokeWidth="1.5"
|
{flexRender(
|
||||||
strokeLinecap="round"
|
cell.column.columnDef.cell,
|
||||||
strokeLinejoin="round"
|
cell.getContext(),
|
||||||
>
|
)}
|
||||||
<rect x="2" y="7" width="20" height="14" rx="2" ry="2" />
|
</td>
|
||||||
<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16" />
|
))}
|
||||||
</svg>
|
</tr>
|
||||||
}
|
))
|
||||||
title="No gateways yet"
|
) : (
|
||||||
description="Create your first gateway to connect boards and start managing your OpenClaw connections."
|
<TableEmptyStateRow
|
||||||
actionHref="/gateways/new"
|
colSpan={columns.length}
|
||||||
actionLabel="Create your first gateway"
|
icon={
|
||||||
/>
|
<svg
|
||||||
)}
|
className="h-16 w-16 text-slate-300"
|
||||||
</tbody>
|
viewBox="0 0 24 24"
|
||||||
</table>
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.5"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
>
|
||||||
|
<rect
|
||||||
|
x="2"
|
||||||
|
y="7"
|
||||||
|
width="20"
|
||||||
|
height="14"
|
||||||
|
rx="2"
|
||||||
|
ry="2"
|
||||||
|
/>
|
||||||
|
<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
title="No gateways yet"
|
||||||
|
description="Create your first gateway to connect boards and start managing your OpenClaw connections."
|
||||||
|
actionHref="/gateways/new"
|
||||||
|
actionLabel="Create your first gateway"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{gatewaysQuery.error ? (
|
{gatewaysQuery.error ? (
|
||||||
<p className="mt-4 text-sm text-red-500">{gatewaysQuery.error.message}</p>
|
<p className="mt-4 text-sm text-red-500">
|
||||||
) : null}
|
{gatewaysQuery.error.message}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</DashboardPageLayout>
|
</DashboardPageLayout>
|
||||||
|
|
||||||
<ConfirmActionDialog
|
<ConfirmActionDialog
|
||||||
|
|||||||
@@ -378,7 +378,9 @@ export default function OrganizationPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const membershipRole =
|
const membershipRole =
|
||||||
membershipQuery.data?.status === 200 ? membershipQuery.data.data.role : null;
|
membershipQuery.data?.status === 200
|
||||||
|
? membershipQuery.data.data.role
|
||||||
|
: null;
|
||||||
const isOwner = membershipRole === "owner";
|
const isOwner = membershipRole === "owner";
|
||||||
const isAdmin = membershipRole === "admin" || membershipRole === "owner";
|
const isAdmin = membershipRole === "admin" || membershipRole === "owner";
|
||||||
|
|
||||||
@@ -842,7 +844,9 @@ export default function OrganizationPage() {
|
|||||||
onClick={() => setInviteDialogOpen(true)}
|
onClick={() => setInviteDialogOpen(true)}
|
||||||
disabled={!isAdmin}
|
disabled={!isAdmin}
|
||||||
title={
|
title={
|
||||||
isAdmin ? undefined : "Only organization admins can invite"
|
isAdmin
|
||||||
|
? undefined
|
||||||
|
: "Only organization admins can invite"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<UserPlus className="h-4 w-4" />
|
<UserPlus className="h-4 w-4" />
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
|
||||||
import { ActivityFeed } from "./ActivityFeed";
|
import { ActivityFeed } from "./ActivityFeed";
|
||||||
|
|
||||||
type Item = { id: string; label: string };
|
type Item = { id: string; label: string };
|
||||||
@@ -56,9 +55,7 @@ describe("ActivityFeed", () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(
|
expect(screen.getByText("Waiting for new comments…")).toBeInTheDocument();
|
||||||
screen.getByText("Waiting for new comments…"),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
expect(
|
expect(
|
||||||
screen.getByText("When agents post updates, they will show up here."),
|
screen.getByText("When agents post updates, they will show up here."),
|
||||||
).toBeInTheDocument();
|
).toBeInTheDocument();
|
||||||
|
|||||||
@@ -159,10 +159,17 @@ export function GatewayForm({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{errorMessage ? <p className="text-sm text-red-500">{errorMessage}</p> : null}
|
{errorMessage ? (
|
||||||
|
<p className="text-sm text-red-500">{errorMessage}</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="flex justify-end gap-3">
|
<div className="flex justify-end gap-3">
|
||||||
<Button type="button" variant="ghost" onClick={onCancel} disabled={isLoading}>
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onCancel}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
{cancelLabel}
|
{cancelLabel}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" disabled={isLoading || !canSubmit}>
|
<Button type="submit" disabled={isLoading || !canSubmit}>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { createExponentialBackoff } from "./backoff";
|
import { createExponentialBackoff } from "./backoff";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
|
||||||
describe("createExponentialBackoff", () => {
|
describe("createExponentialBackoff", () => {
|
||||||
it("uses default options", () => {
|
it("uses default options", () => {
|
||||||
const backoff = createExponentialBackoff();
|
const backoff = createExponentialBackoff();
|
||||||
|
|||||||
@@ -50,7 +50,8 @@ export async function checkGatewayConnection(params: {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
return {
|
return {
|
||||||
ok: false,
|
ok: false,
|
||||||
message: error instanceof Error ? error.message : "Unable to reach gateway.",
|
message:
|
||||||
|
error instanceof Error ? error.message : "Unable to reach gateway.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ export default defineConfig({
|
|||||||
reportsDirectory: "./coverage",
|
reportsDirectory: "./coverage",
|
||||||
// Policy (scoped gate): require 100% coverage on *explicitly listed* unit-testable modules first.
|
// Policy (scoped gate): require 100% coverage on *explicitly listed* unit-testable modules first.
|
||||||
// We'll expand this include list as we add more unit/component tests.
|
// We'll expand this include list as we add more unit/component tests.
|
||||||
include: ["src/lib/backoff.ts", "src/components/activity/ActivityFeed.tsx"],
|
include: [
|
||||||
|
"src/lib/backoff.ts",
|
||||||
|
"src/components/activity/ActivityFeed.tsx",
|
||||||
|
],
|
||||||
exclude: ["**/*.d.ts", "src/**/__generated__/**", "src/**/generated/**"],
|
exclude: ["**/*.d.ts", "src/**/__generated__/**", "src/**/generated/**"],
|
||||||
thresholds: {
|
thresholds: {
|
||||||
lines: 100,
|
lines: 100,
|
||||||
|
|||||||
Reference in New Issue
Block a user