Skip to content
10 changes: 6 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,11 @@ on:
jobs:
build-and-lint:
runs-on: ubuntu-latest
timeout-minutes: 15

strategy:
matrix:
node-version: [24.x]
node-version: [22.x]

steps:
- name: Checkout repository
Expand Down Expand Up @@ -55,15 +56,16 @@ jobs:
test-e2e:
runs-on: ubuntu-latest
needs: build-and-lint
timeout-minutes: 15

steps:
- name: Checkout repository
uses: actions/checkout@v4

- name: Setup Node.js 24.x
- name: Setup Node.js 22.x
uses: actions/setup-node@v4
with:
node-version: 24.x
node-version: 22.x

- name: Setup pnpm
uses: pnpm/action-setup@v4
Expand Down Expand Up @@ -92,7 +94,7 @@ jobs:
NEXT_PUBLIC_NATIVE_TOKEN_CONTRACT: ${{ secrets.NEXT_PUBLIC_NATIVE_TOKEN_CONTRACT || 'CDLZFC3SYJYDZT7K67VZ75HPJVIEUVNIXF47ZG2FB2RMQQVU2HHGCYSC' }}

- name: Install Playwright Chromium
run: pnpm exec playwright install chromium --with-deps
run: pnpm exec playwright install chromium

- name: Run E2E tests
run: pnpm run test:e2e
Expand Down
1 change: 1 addition & 0 deletions components/bounty-detail/bounty-detail-client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,7 @@ export function BountyDetailClient({ bountyId }: { bountyId: string }) {
getFullMilestoneData(bounty);
return (
<Model4MaintainerDashboard
bountyId={bountyId}
milestones={milestones}
contributors={contributorProgress}
/>
Expand Down
218 changes: 172 additions & 46 deletions components/bounty-detail/model4-maintainer-dashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,22 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { toast } from "sonner";
import {
useReleasePayment,
useAdvanceContributor,
useRemoveContributor,
useSendMessage,
} from "@/hooks/use-bounty-application";
import {
ChevronRight,
UserMinus,
Expand All @@ -23,25 +39,86 @@ import {
} from "lucide-react";

interface Model4MaintainerDashboardProps {
bountyId: string;
milestones: Milestone[];
contributors: ContributorProgress[];
maxSlots?: number;
className?: string;
}

export function Model4MaintainerDashboard({
bountyId,
milestones,
contributors: initialContributors,
maxSlots = 5,
className,
}: Model4MaintainerDashboardProps) {
const [loadingAction, setLoadingAction] = React.useState<string | null>(null);
const releasePayment = useReleasePayment(bountyId);
const advanceContributor = useAdvanceContributor(bountyId);
const removeContributor = useRemoveContributor(bountyId);
const sendMessage = useSendMessage(bountyId);

const [selectedContributor, setSelectedContributor] =
React.useState<ContributorProgress | null>(null);
const [isSubmissionsOpen, setIsSubmissionsOpen] = React.useState(false);
const [isMessageOpen, setIsMessageOpen] = React.useState(false);
const [messageText, setMessageText] = React.useState("");

const handleReleasePayment = (contributor: ContributorProgress) => {
releasePayment.mutate(
{
contributorId: contributor.userId,
milestoneId: contributor.currentMilestoneId,
},
{
onSuccess: () =>
toast.success(`Payment released for ${contributor.userName}`),
},
);
};
Comment on lines +67 to +78
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Missing onError callbacks on mutation calls.

The mutate calls only handle onSuccess but don't handle onError. If the hook-level onError doesn't show a toast (which it currently doesn't), users won't know their action failed.

Add onError callbacks to show error toasts
   const handleReleasePayment = (contributor: ContributorProgress) => {
     releasePayment.mutate(
       {
         contributorId: contributor.userId,
         milestoneId: contributor.currentMilestoneId,
       },
       {
         onSuccess: () =>
           toast.success(`Payment released for ${contributor.userName}`),
+        onError: () =>
+          toast.error(`Failed to release payment for ${contributor.userName}`),
       },
     );
   };

   const handleAdvance = (contributor: ContributorProgress) => {
     advanceContributor.mutate(
       { contributorId: contributor.userId },
       {
         onSuccess: () =>
           toast.success(`${contributor.userName} advanced to next milestone`),
+        onError: () =>
+          toast.error(`Failed to advance ${contributor.userName}`),
       },
     );
   };

   const handleRemove = (contributor: ContributorProgress) => {
     removeContributor.mutate(
       { contributorId: contributor.userId },
       {
         onSuccess: () =>
           toast.success(`${contributor.userName} removed from bounty`),
+        onError: () =>
+          toast.error(`Failed to remove ${contributor.userName}`),
       },
     );
   };

Also applies to: 73-81, 83-91

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/bounty-detail/model4-maintainer-dashboard.tsx` around lines 60 -
71, Add onError handlers to the mutation calls so failures surface to users; for
each mutate invocation (e.g., releasePayment.mutate in handleReleasePayment and
the other mutate calls referenced around the same area like
approveContribution.mutate and rejectContribution.mutate), pass an onError
callback that calls toast.error with a clear message that includes the error
message or a fallback (e.g., `toast.error(\`Failed to release payment:
${error?.message ?? 'unknown error'}\`)`). Ensure the onError is paired with the
existing onSuccess handlers and uses the mutation-specific context
(contributor.userName or relevant IDs) to make the toast informative.


const handleAdvance = (contributor: ContributorProgress) => {
advanceContributor.mutate(
{ contributorId: contributor.userId },
{
onSuccess: () =>
toast.success(`${contributor.userName} advanced to next milestone`),
},
);
};

const handleRemove = (contributor: ContributorProgress) => {
removeContributor.mutate(
{ contributorId: contributor.userId },
{
onSuccess: () =>
toast.success(`${contributor.userName} removed from bounty`),
},
);
};

const handleOpenSubmissions = (contributor: ContributorProgress) => {
setSelectedContributor(contributor);
setIsSubmissionsOpen(true);
};

const handleOpenMessage = (contributor: ContributorProgress) => {
setSelectedContributor(contributor);
setMessageText("");
setIsMessageOpen(true);
};

const handleAction = async (action: string, userName: string) => {
setLoadingAction(`${action}-${userName}`);
console.log(`[Coming soon] ${action} for ${userName}`);
await new Promise((r) => setTimeout(r, 1000));
setLoadingAction(null);
const handleSendMessage = () => {
if (!selectedContributor || !messageText.trim()) return;
sendMessage.mutate(
{ contributorId: selectedContributor.userId, message: messageText },
{
onSuccess: () => {
toast.success(`Message sent to ${selectedContributor.userName}`);
setIsMessageOpen(false);
},
},
);
};
Comment on lines +111 to 122
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Missing onError handler for sendMessage mutation.

Similar to other handlers, handleSendMessage should handle errors so users know if message delivery failed.

Add onError callback
   const handleSendMessage = () => {
     if (!selectedContributor || !messageText.trim()) return;
     sendMessage.mutate(
       { contributorId: selectedContributor.userId, message: messageText },
       {
         onSuccess: () => {
           toast.success(`Message sent to ${selectedContributor.userName}`);
           setIsMessageOpen(false);
         },
+        onError: () => {
+          toast.error(`Failed to send message to ${selectedContributor.userName}`);
+        },
       },
     );
   };
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const handleSendMessage = () => {
if (!selectedContributor || !messageText.trim()) return;
sendMessage.mutate(
{ contributorId: selectedContributor.userId, message: messageText },
{
onSuccess: () => {
toast.success(`Message sent to ${selectedContributor.userName}`);
setIsMessageOpen(false);
},
},
);
};
const handleSendMessage = () => {
if (!selectedContributor || !messageText.trim()) return;
sendMessage.mutate(
{ contributorId: selectedContributor.userId, message: messageText },
{
onSuccess: () => {
toast.success(`Message sent to ${selectedContributor.userName}`);
setIsMessageOpen(false);
},
onError: () => {
toast.error(`Failed to send message to ${selectedContributor.userName}`);
},
},
);
};
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/bounty-detail/model4-maintainer-dashboard.tsx` around lines 104 -
115, handleSendMessage currently calls sendMessage.mutate without an onError
handler; update handleSendMessage to pass an onError callback to
sendMessage.mutate (alongside the existing onSuccess) that shows an error toast
(e.g., toast.error with a descriptive message and optional error.message) and
keeps the message UI open or resets state appropriately. Locate the
sendMessage.mutate call inside the handleSendMessage function and add the
onError option to mirror other mutation handlers in this file.


return (
Expand Down Expand Up @@ -135,18 +212,12 @@ export function Model4MaintainerDashboard({
variant="ghost"
size="icon-sm"
className="text-gray-400 hover:text-white"
aria-label="Send message"
onClick={() =>
handleAction("Message", contributor.userName)
}
disabled={loadingAction !== null}
onClick={() => handleOpenMessage(contributor)}
>
<MessageSquare className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>
Send Message [Coming soon]
</TooltipContent>
<TooltipContent>Send Message</TooltipContent>
</Tooltip>

<Tooltip>
Expand All @@ -155,15 +226,9 @@ export function Model4MaintainerDashboard({
variant="outline"
size="sm"
className="h-8 text-xs border-gray-700 hover:bg-gray-800"
onClick={() =>
handleAction(
"View Submissions",
contributor.userName,
)
}
disabled={loadingAction !== null}
onClick={() => handleOpenSubmissions(contributor)}
>
View Submissions [Coming soon]
View Submissions
</Button>
</TooltipTrigger>
<TooltipContent>Review work</TooltipContent>
Expand All @@ -174,21 +239,21 @@ export function Model4MaintainerDashboard({
<Button
size="sm"
className="h-8 text-xs bg-emerald-500/10 text-emerald-400 hover:bg-emerald-500/20 border border-emerald-500/20 font-bold"
onClick={() =>
handleAction(
"Release Payment",
contributor.userName,
)
onClick={() => handleReleasePayment(contributor)}
disabled={
releasePayment.isPending &&
releasePayment.variables?.contributorId ===
contributor.userId
}
disabled={loadingAction !== null}
>
{loadingAction ===
`Release Payment-${contributor.userName}` ? (
{releasePayment.isPending &&
releasePayment.variables?.contributorId ===
contributor.userId ? (
<Loader2 className="size-3 mr-1.5 animate-spin" />
) : (
<Coins className="size-3 mr-1.5" />
)}
Release Payment [Coming soon]
Release Payment
</Button>
</TooltipTrigger>
<TooltipContent>Pay for milestone</TooltipContent>
Expand All @@ -200,18 +265,20 @@ export function Model4MaintainerDashboard({
size="sm"
variant="secondary"
className="h-8 text-xs font-bold"
onClick={() =>
handleAction("Advance", contributor.userName)
onClick={() => handleAdvance(contributor)}
disabled={
advanceContributor.isPending &&
advanceContributor.variables?.contributorId ===
contributor.userId
}
disabled={loadingAction !== null}
>
{loadingAction ===
`Advance-${contributor.userName}` ? (
{advanceContributor.isPending &&
advanceContributor.variables?.contributorId ===
contributor.userId ? (
<Loader2 className="size-3 mr-1.5 animate-spin" />
) : (
<>
Advance [Coming soon]{" "}
<ArrowRight className="size-3 ml-1.5" />
Advance <ArrowRight className="size-3 ml-1.5" />
</>
)}
Comment on lines +275 to 283
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Advance button label missing during loading state.

When advanceContributor.isPending is true for this contributor, only the Loader2 spinner is rendered. The "Advance" text and arrow icon are lost, unlike the other buttons that keep their labels during loading.

Keep label visible during loading
                           >
-                            {advanceContributor.isPending &&
-                            advanceContributor.variables?.contributorId ===
-                              contributor.userId ? (
+                            {advanceContributor.isPending &&
+                            advanceContributor.variables?.contributorId ===
+                              contributor.userId && (
                               <Loader2 className="size-3 mr-1.5 animate-spin" />
-                            ) : (
-                              <>
-                                Advance <ArrowRight className="size-3 ml-1.5" />
-                              </>
                             )}
+                            Advance <ArrowRight className="size-3 ml-1.5" />
                           </Button>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
{advanceContributor.isPending &&
advanceContributor.variables?.contributorId ===
contributor.userId ? (
<Loader2 className="size-3 mr-1.5 animate-spin" />
) : (
<>
Advance [Coming soon]{" "}
<ArrowRight className="size-3 ml-1.5" />
Advance <ArrowRight className="size-3 ml-1.5" />
</>
)}
{advanceContributor.isPending &&
advanceContributor.variables?.contributorId ===
contributor.userId && (
<Loader2 className="size-3 mr-1.5 animate-spin" />
)}
Advance <ArrowRight className="size-3 ml-1.5" />
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@components/bounty-detail/model4-maintainer-dashboard.tsx` around lines 268 -
276, The Advance button currently renders only Loader2 when
advanceContributor.isPending for the contributor, removing the "Advance" text
and ArrowRight icon; update the JSX in the Advance button (the block using
advanceContributor, contributor.userId, Loader2 and ArrowRight) to always render
the "Advance" label and ArrowRight icon and conditionally render Loader2
alongside them when advanceContributor.isPending (e.g., show Loader2
before/after the label and keep the label+icon visible), preserving the existing
conditional check on advanceContributor.variables?.contributorId to target the
correct contributor.

</Button>
Expand All @@ -225,18 +292,23 @@ export function Model4MaintainerDashboard({
variant="ghost"
size="icon-sm"
className="text-red-400/50 hover:text-red-400 hover:bg-red-400/10"
aria-label="Remove from slot"
onClick={() =>
handleAction("Remove", contributor.userName)
onClick={() => handleRemove(contributor)}
disabled={
removeContributor.isPending &&
removeContributor.variables?.contributorId ===
contributor.userId
}
disabled={loadingAction !== null}
>
<UserMinus className="size-4" />
{removeContributor.isPending &&
removeContributor.variables?.contributorId ===
contributor.userId ? (
<Loader2 className="size-4 animate-spin" />
) : (
<UserMinus className="size-4" />
)}
</Button>
</TooltipTrigger>
<TooltipContent>
Remove from slot [Coming soon]
</TooltipContent>
<TooltipContent>Remove from slot</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
Expand Down Expand Up @@ -273,6 +345,60 @@ export function Model4MaintainerDashboard({
</Tooltip>
</TooltipProvider>
</div>

{/* Modals */}
<Dialog open={isSubmissionsOpen} onOpenChange={setIsSubmissionsOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>
Submissions for {selectedContributor?.userName}
</DialogTitle>
<DialogDescription>
Review the submitted work from this contributor.
</DialogDescription>
</DialogHeader>
<div className="py-6 text-center text-gray-400">
No submissions found for this contributor.
</div>
<DialogFooter>
<Button onClick={() => setIsSubmissionsOpen(false)}>Close</Button>
</DialogFooter>
</DialogContent>
</Dialog>

<Dialog open={isMessageOpen} onOpenChange={setIsMessageOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Message {selectedContributor?.userName}</DialogTitle>
<DialogDescription>
Send a message directly to this contributor regarding their
application.
</DialogDescription>
</DialogHeader>
<div className="py-4">
<Textarea
placeholder="Type your message here..."
value={messageText}
onChange={(e) => setMessageText(e.target.value)}
className="min-h-[100px]"
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsMessageOpen(false)}>
Cancel
</Button>
<Button
onClick={handleSendMessage}
disabled={sendMessage.isPending || !messageText.trim()}
>
{sendMessage.isPending && (
<Loader2 className="size-3 mr-1.5 animate-spin" />
)}
Send Message
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</CardContent>
</Card>
);
Expand Down
Loading