refactor: clean up code formatting and improve readability in various files

This commit is contained in:
Abhimanyu Saharan
2026-02-09 00:29:26 +05:30
parent bb5a8482f3
commit 8c4bcca603
20 changed files with 1188 additions and 1146 deletions

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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>
); );

View File

@@ -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>
); );

View File

@@ -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}

View File

@@ -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>
); );

View File

@@ -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>
); );

View File

@@ -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

View File

@@ -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>

View File

@@ -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

View File

@@ -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}

View File

@@ -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>

View File

@@ -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

View File

@@ -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" />

View File

@@ -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();

View File

@@ -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}>

View File

@@ -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();

View File

@@ -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.",
}; };
} }
} }

View File

@@ -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,