From 540e8fb6c0a72854d762ab6854b134eaf3b46b6e Mon Sep 17 00:00:00 2001 From: Cyrus Gray <144336577+graycyrus@users.noreply.github.com> Date: Fri, 19 Jun 2026 23:22:07 +0530 Subject: [PATCH] feat(agent-world): add job-apply agent tool + apply success UX (#3848) --- app/src/agentworld/pages/JobsSection.test.tsx | 99 +++++++ app/src/agentworld/pages/JobsSection.tsx | 146 ++++++---- app/src/lib/i18n/ar.ts | 12 + app/src/lib/i18n/bn.ts | 13 + app/src/lib/i18n/de.ts | 13 + app/src/lib/i18n/en.ts | 15 + app/src/lib/i18n/es.ts | 13 + app/src/lib/i18n/fr.ts | 13 + app/src/lib/i18n/hi.ts | 13 + app/src/lib/i18n/id.ts | 13 + app/src/lib/i18n/it.ts | 14 + app/src/lib/i18n/ko.ts | 13 + app/src/lib/i18n/pl.ts | 13 + app/src/lib/i18n/pt.ts | 13 + app/src/lib/i18n/ru.ts | 13 + app/src/lib/i18n/zh-CN.ts | 12 + src/openhuman/tinyplace/mod.rs | 1 + src/openhuman/tinyplace/tools.rs | 270 ++++++++++++++++++ src/openhuman/tools/mod.rs | 1 + src/openhuman/tools/ops.rs | 5 + 20 files changed, 651 insertions(+), 54 deletions(-) create mode 100644 src/openhuman/tinyplace/tools.rs diff --git a/app/src/agentworld/pages/JobsSection.test.tsx b/app/src/agentworld/pages/JobsSection.test.tsx index 4baa333eb..e38446d8f 100644 --- a/app/src/agentworld/pages/JobsSection.test.tsx +++ b/app/src/agentworld/pages/JobsSection.test.tsx @@ -472,6 +472,105 @@ describe('Jobs write actions', () => { }); }); + test('Apply success shows success state and triggers refetch', async () => { + const user = userEvent.setup(); + vi.mocked(fetchWalletStatus).mockResolvedValue(sampleWalletStatus as any); + // First call: initial load; second call should happen after successful apply. + vi.mocked(apiClient.graphql.jobs).mockResolvedValue({ jobs: [sampleJob], count: 1 }); + vi.mocked(apiClient.jobsWrite.apply).mockResolvedValue(sampleProposal as any); + + render(); + await waitFor(() => { + expect(screen.getByText('Build a dashboard widget')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Build a dashboard widget')); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /^apply$/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /^apply$/i })); + + await user.click(screen.getByRole('button', { name: /submit application/i })); + + // After success, the jobs list should be refetched (called a second time). + await waitFor(() => { + expect(vi.mocked(apiClient.graphql.jobs)).toHaveBeenCalledTimes(2); + }); + }); + + test('Apply form shows error on failure', async () => { + const user = userEvent.setup(); + vi.mocked(fetchWalletStatus).mockResolvedValue(sampleWalletStatus as any); + vi.mocked(apiClient.graphql.jobs).mockResolvedValue({ jobs: [sampleJob], count: 1 }); + vi.mocked(apiClient.jobsWrite.apply).mockRejectedValue(new Error('Network error')); + + render(); + await waitFor(() => { + expect(screen.getByText('Build a dashboard widget')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Build a dashboard widget')); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /^apply$/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /^apply$/i })); + + await user.click(screen.getByRole('button', { name: /submit application/i })); + + await waitFor(() => { + // The error message should appear in the modal. + expect(screen.getByRole('alert')).toBeInTheDocument(); + expect(screen.getByRole('alert')).toHaveTextContent(/network error/i); + }); + + // Modal stays open so user can retry. + expect(screen.getByRole('button', { name: /submit application/i })).toBeInTheDocument(); + // Refetch should NOT have been called on failure. + expect(vi.mocked(apiClient.graphql.jobs)).toHaveBeenCalledTimes(1); + }); + + test('Apply button shows loading state while submitting', async () => { + const user = userEvent.setup(); + vi.mocked(fetchWalletStatus).mockResolvedValue(sampleWalletStatus as any); + vi.mocked(apiClient.graphql.jobs).mockResolvedValue({ jobs: [sampleJob], count: 1 }); + + let resolveApply: (v: unknown) => void; + vi.mocked(apiClient.jobsWrite.apply).mockReturnValue( + new Promise(resolve => { + resolveApply = resolve; + }) as any + ); + + render(); + await waitFor(() => { + expect(screen.getByText('Build a dashboard widget')).toBeInTheDocument(); + }); + + await user.click(screen.getByText('Build a dashboard widget')); + + await waitFor(() => { + expect(screen.getByRole('button', { name: /^apply$/i })).toBeInTheDocument(); + }); + + await user.click(screen.getByRole('button', { name: /^apply$/i })); + + await user.click(screen.getByRole('button', { name: /submit application/i })); + + // While the request is in-flight, the button shows "Applying..." and is disabled. + await waitFor(() => { + const btn = screen.getByRole('button', { name: /applying/i }); + expect(btn).toBeInTheDocument(); + expect(btn).toBeDisabled(); + }); + + // Clean up the pending promise. + resolveApply!(sampleProposal); + }); + test('Own job shows Cancel Job and View Proposals buttons', async () => { const user = userEvent.setup(); vi.mocked(fetchWalletStatus).mockResolvedValue({ diff --git a/app/src/agentworld/pages/JobsSection.tsx b/app/src/agentworld/pages/JobsSection.tsx index 73206f6e8..850b88348 100644 --- a/app/src/agentworld/pages/JobsSection.tsx +++ b/app/src/agentworld/pages/JobsSection.tsx @@ -14,7 +14,7 @@ * Pattern mirrors LedgerSection / FeedSection: useState + useEffect fetch, * PanelScaffold wrapper, StatusBlock for loading/error/empty states. */ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import PanelScaffold from '../../components/layout/PanelScaffold'; import Button from '../../components/ui/Button'; @@ -368,11 +368,23 @@ function ApplyModal({ onClose: () => void; onApplied: () => void; }) { + const { t } = useT(); const [coverLetter, setCoverLetter] = useState(''); const [bidAmount, setBidAmount] = useState(''); const [estimatedDelivery, setEstimatedDelivery] = useState(''); const [submitting, setSubmitting] = useState(false); + const [succeeded, setSucceeded] = useState(false); const [error, setError] = useState(null); + const successTimerRef = useRef | null>(null); + + // Clear the auto-close timer on unmount to avoid state updates after teardown. + useEffect(() => { + return () => { + if (successTimerRef.current) { + clearTimeout(successTimerRef.current); + } + }; + }, []); const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -385,8 +397,12 @@ function ApplyModal({ }; try { await apiClient.jobsWrite.apply(jobId, params); + setSucceeded(true); onApplied(); - onClose(); + // Auto-close after a short success display window. + successTimerRef.current = setTimeout(() => { + onClose(); + }, 1500); } catch (err) { setError(String(err)); } finally { @@ -397,60 +413,80 @@ function ApplyModal({ return ( -
{ - void handleSubmit(e); - }} - className="space-y-3"> -
- -