From 05c31102fdc80536d0919dfee37075941b5b74bf Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Tue, 17 Feb 2026 08:39:59 +0000 Subject: [PATCH 1/2] feat(dashboard): add manual trigger & retry run actions to web dashboard --- tests/unit/api/routers/runs.test.ts | 201 +++++++++++++++++- web/src/components/runs/retry-run-button.tsx | 43 ++++ web/src/components/runs/runs-table.tsx | 7 +- .../components/runs/trigger-run-dialog.tsx | 187 ++++++++++++++++ web/src/routes/index.tsx | 18 +- web/src/routes/runs/$runId.tsx | 2 + 6 files changed, 453 insertions(+), 5 deletions(-) create mode 100644 web/src/components/runs/retry-run-button.tsx create mode 100644 web/src/components/runs/trigger-run-dialog.tsx diff --git a/tests/unit/api/routers/runs.test.ts b/tests/unit/api/routers/runs.test.ts index efc88bca..3e35e3b9 100644 --- a/tests/unit/api/routers/runs.test.ts +++ b/tests/unit/api/routers/runs.test.ts @@ -48,6 +48,14 @@ vi.mock('../../../../src/triggers/shared/debug-runner.js', () => ({ triggerDebugAnalysis: (...args: unknown[]) => mockTriggerDebugAnalysis(...args), })); +// Mock triggerManualRun and triggerRetryRun (fire-and-forget) +const mockTriggerManualRun = vi.fn(); +const mockTriggerRetryRun = vi.fn(); +vi.mock('../../../../src/triggers/shared/manual-runner.js', () => ({ + triggerManualRun: (...args: unknown[]) => mockTriggerManualRun(...args), + triggerRetryRun: (...args: unknown[]) => mockTriggerRetryRun(...args), +})); + // Mock config provider const mockFindProjectById = vi.fn(); const mockLoadConfig = vi.fn(); @@ -83,8 +91,10 @@ describe('runsRouter', () => { // Set up DB chain for getById org check mockDbSelect.mockReturnValue({ from: mockDbFrom }); mockDbFrom.mockReturnValue({ where: mockDbWhere }); - // Default: triggerDebugAnalysis returns a resolved promise (fire-and-forget) + // Default: fire-and-forget mocks return resolved promises mockTriggerDebugAnalysis.mockReturnValue(Promise.resolve()); + mockTriggerManualRun.mockReturnValue(Promise.resolve()); + mockTriggerRetryRun.mockReturnValue(Promise.resolve()); }); describe('list', () => { @@ -501,4 +511,193 @@ describe('runsRouter', () => { }); }); }); + + describe('trigger', () => { + it('fires a manual run and returns triggered:true', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + mockFindProjectById.mockResolvedValue({ id: 'p1', name: 'Test Project' }); + mockLoadConfig.mockResolvedValue({}); + + const caller = createCaller({ user: mockUser }); + const result = await caller.trigger({ + projectId: 'p1', + agentType: 'implementation', + cardId: 'card-abc', + }); + + expect(result).toEqual({ triggered: true }); + expect(mockTriggerManualRun).toHaveBeenCalledWith( + expect.objectContaining({ + projectId: 'p1', + agentType: 'implementation', + cardId: 'card-abc', + }), + { id: 'p1', name: 'Test Project' }, + {}, + ); + }); + + it('passes optional fields when provided', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + mockFindProjectById.mockResolvedValue({ id: 'p1', name: 'Test Project' }); + mockLoadConfig.mockResolvedValue({}); + + const caller = createCaller({ user: mockUser }); + await caller.trigger({ + projectId: 'p1', + agentType: 'review', + prNumber: 42, + prBranch: 'feature/my-branch', + model: 'claude-opus-4-5', + }); + + expect(mockTriggerManualRun).toHaveBeenCalledWith( + expect.objectContaining({ + prNumber: 42, + prBranch: 'feature/my-branch', + modelOverride: 'claude-opus-4-5', + }), + expect.anything(), + expect.anything(), + ); + }); + + it('throws NOT_FOUND when project does not exist in DB', async () => { + mockDbWhere.mockResolvedValue([]); + + const caller = createCaller({ user: mockUser }); + await expect( + caller.trigger({ projectId: 'missing', agentType: 'implementation' }), + ).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); + + it('throws NOT_FOUND when project belongs to different org', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'other-org' }]); + + const caller = createCaller({ user: mockUser }); + await expect( + caller.trigger({ projectId: 'p1', agentType: 'implementation' }), + ).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); + + it('throws NOT_FOUND when project config not found', async () => { + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + mockFindProjectById.mockResolvedValue(undefined); + + const caller = createCaller({ user: mockUser }); + await expect( + caller.trigger({ projectId: 'p1', agentType: 'implementation' }), + ).rejects.toMatchObject({ code: 'NOT_FOUND' }); + }); + + it('throws UNAUTHORIZED when unauthenticated', async () => { + const caller = createCaller({ user: null }); + await expect( + caller.trigger({ projectId: 'p1', agentType: 'implementation' }), + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + }); + }); + + describe('retry', () => { + it('fires a retry run and returns triggered:true', async () => { + mockGetRunById.mockResolvedValue({ + id: RUN_UUID, + projectId: 'p1', + agentType: 'implementation', + }); + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + mockFindProjectById.mockResolvedValue({ id: 'p1', name: 'Test Project' }); + mockLoadConfig.mockResolvedValue({}); + + const caller = createCaller({ user: mockUser }); + const result = await caller.retry({ runId: RUN_UUID }); + + expect(result).toEqual({ triggered: true }); + expect(mockTriggerRetryRun).toHaveBeenCalledWith( + RUN_UUID, + { id: 'p1', name: 'Test Project' }, + {}, + undefined, + ); + }); + + it('passes model override when provided', async () => { + mockGetRunById.mockResolvedValue({ + id: RUN_UUID, + projectId: 'p1', + agentType: 'implementation', + }); + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + mockFindProjectById.mockResolvedValue({ id: 'p1', name: 'Test Project' }); + mockLoadConfig.mockResolvedValue({}); + + const caller = createCaller({ user: mockUser }); + await caller.retry({ runId: RUN_UUID, model: 'claude-opus-4-5' }); + + expect(mockTriggerRetryRun).toHaveBeenCalledWith( + RUN_UUID, + expect.anything(), + expect.anything(), + 'claude-opus-4-5', + ); + }); + + it('throws NOT_FOUND when run does not exist', async () => { + mockGetRunById.mockResolvedValue(null); + + const caller = createCaller({ user: mockUser }); + await expect(caller.retry({ runId: RUN_UUID })).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); + }); + + it('throws NOT_FOUND when org does not match', async () => { + mockGetRunById.mockResolvedValue({ + id: RUN_UUID, + projectId: 'p1', + agentType: 'implementation', + }); + mockDbWhere.mockResolvedValue([{ orgId: 'different-org' }]); + + const caller = createCaller({ user: mockUser }); + await expect(caller.retry({ runId: RUN_UUID })).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); + }); + + it('throws BAD_REQUEST when run has no projectId', async () => { + mockGetRunById.mockResolvedValue({ + id: RUN_UUID, + projectId: null, + agentType: 'implementation', + }); + + const caller = createCaller({ user: mockUser }); + await expect(caller.retry({ runId: RUN_UUID })).rejects.toMatchObject({ + code: 'BAD_REQUEST', + }); + }); + + it('throws NOT_FOUND when project config not found', async () => { + mockGetRunById.mockResolvedValue({ + id: RUN_UUID, + projectId: 'p-missing', + agentType: 'implementation', + }); + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + mockFindProjectById.mockResolvedValue(undefined); + + const caller = createCaller({ user: mockUser }); + await expect(caller.retry({ runId: RUN_UUID })).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); + }); + + it('throws UNAUTHORIZED when unauthenticated', async () => { + const caller = createCaller({ user: null }); + await expect(caller.retry({ runId: RUN_UUID })).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + }); + }); }); diff --git a/web/src/components/runs/retry-run-button.tsx b/web/src/components/runs/retry-run-button.tsx new file mode 100644 index 00000000..c0888112 --- /dev/null +++ b/web/src/components/runs/retry-run-button.tsx @@ -0,0 +1,43 @@ +import { Button } from '@/components/ui/button.js'; +import { trpc, trpcClient } from '@/lib/trpc.js'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { RefreshCw } from 'lucide-react'; + +interface RetryRunButtonProps { + runId: string; + status: string; +} + +export function RetryRunButton({ runId, status }: RetryRunButtonProps) { + const queryClient = useQueryClient(); + + const retryMutation = useMutation({ + mutationFn: () => trpcClient.runs.retry.mutate({ runId }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: trpc.runs.list.queryOptions({}).queryKey }); + queryClient.invalidateQueries({ + queryKey: trpc.runs.getById.queryOptions({ id: runId }).queryKey, + }); + }, + }); + + if (status === 'running') { + return null; + } + + return ( + + ); +} diff --git a/web/src/components/runs/runs-table.tsx b/web/src/components/runs/runs-table.tsx index d0cf5e1b..b931eb58 100644 --- a/web/src/components/runs/runs-table.tsx +++ b/web/src/components/runs/runs-table.tsx @@ -1,6 +1,7 @@ import { formatCost, formatDuration, formatRelativeTime } from '@/lib/utils.js'; import { Link } from '@tanstack/react-router'; import { ExternalLink } from 'lucide-react'; +import { RetryRunButton } from './retry-run-button.js'; import { RunStatusBadge } from './run-status-badge.js'; interface Run { @@ -41,12 +42,13 @@ export function RunsTable({ runs, total, offset, limit, onPageChange }: RunsTabl Cost Iterations PR + Actions {runs.length === 0 && ( - + No runs found @@ -91,6 +93,9 @@ export function RunsTable({ runs, total, offset, limit, onPageChange }: RunsTabl '-' )} + + + ))} diff --git a/web/src/components/runs/trigger-run-dialog.tsx b/web/src/components/runs/trigger-run-dialog.tsx new file mode 100644 index 00000000..42e22541 --- /dev/null +++ b/web/src/components/runs/trigger-run-dialog.tsx @@ -0,0 +1,187 @@ +import { Button } from '@/components/ui/button.js'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog.js'; +import { Input } from '@/components/ui/input.js'; +import { Label } from '@/components/ui/label.js'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select.js'; +import { trpc, trpcClient } from '@/lib/trpc.js'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useState } from 'react'; + +const agentTypes = [ + 'briefing', + 'planning', + 'implementation', + 'review', + 'debug', + 'respond-to-review', + 'respond-to-pr-comment', +]; + +interface TriggerRunDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function TriggerRunDialog({ open, onOpenChange }: TriggerRunDialogProps) { + const queryClient = useQueryClient(); + + const [projectId, setProjectId] = useState(''); + const [agentType, setAgentType] = useState(''); + const [cardId, setCardId] = useState(''); + const [prNumber, setPrNumber] = useState(''); + const [prBranch, setPrBranch] = useState(''); + const [model, setModel] = useState(''); + + const projectsQuery = useQuery(trpc.projects.list.queryOptions()); + + const runsQueryKey = trpc.runs.list.queryOptions({}).queryKey; + + const triggerMutation = useMutation({ + mutationFn: () => + trpcClient.runs.trigger.mutate({ + projectId, + agentType, + cardId: cardId || undefined, + prNumber: prNumber ? Number(prNumber) : undefined, + prBranch: prBranch || undefined, + model: model || undefined, + }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: runsQueryKey }); + onOpenChange(false); + // Reset form + setProjectId(''); + setAgentType(''); + setCardId(''); + setPrNumber(''); + setPrBranch(''); + setModel(''); + }, + }); + + return ( + + + + Trigger Run + +
{ + e.preventDefault(); + triggerMutation.mutate(); + }} + className="space-y-4" + > +
+ + +
+ +
+ + +
+ +
+ + setCardId(e.target.value)} + placeholder="Trello card ID" + /> +
+ +
+
+ + setPrNumber(e.target.value)} + placeholder="e.g. 42" + /> +
+
+ + setPrBranch(e.target.value)} + placeholder="e.g. feature/my-branch" + /> +
+
+ +
+ + setModel(e.target.value)} + placeholder="e.g. claude-opus-4-5-20250929" + /> +
+ +
+ + +
+ + {triggerMutation.isError && ( +

+ {triggerMutation.error instanceof Error + ? triggerMutation.error.message + : 'Failed to trigger run'} +

+ )} +
+
+
+ ); +} diff --git a/web/src/routes/index.tsx b/web/src/routes/index.tsx index 0ab41bae..fb790ed0 100644 --- a/web/src/routes/index.tsx +++ b/web/src/routes/index.tsx @@ -1,8 +1,12 @@ import { RunFilters } from '@/components/runs/run-filters.js'; import { RunsTable } from '@/components/runs/runs-table.js'; +import { TriggerRunDialog } from '@/components/runs/trigger-run-dialog.js'; +import { Button } from '@/components/ui/button.js'; import { trpc } from '@/lib/trpc.js'; import { useQuery } from '@tanstack/react-query'; import { createRoute, useNavigate, useSearch } from '@tanstack/react-router'; +import { Play } from 'lucide-react'; +import { useState } from 'react'; import { z } from 'zod'; import { rootRoute } from './__root.js'; @@ -16,6 +20,7 @@ const searchSchema = z.object({ function RunsListPage() { const navigate = useNavigate({ from: '/' }); const search = useSearch({ from: '/' }); + const [triggerDialogOpen, setTriggerDialogOpen] = useState(false); const projectId = search.projectId ?? ''; const status = search.status ?? ''; @@ -51,10 +56,17 @@ function RunsListPage() {

Agent Runs

- {runsQuery.data && ( - {runsQuery.data.total} total - )} +
+ {runsQuery.data && ( + {runsQuery.data.total} total + )} + +
+ /

{run.agentType}

+
From fa3e40390c76a4dda96d41b2cf4e106d753cd23b Mon Sep 17 00:00:00 2001 From: Cascade Bot Date: Tue, 17 Feb 2026 09:43:53 +0000 Subject: [PATCH 2/2] fix(dashboard): address code review feedback for trigger & retry actions - Add inline error feedback to RetryRunButton (shows "Failed" with error tooltip) - Reset TriggerRunDialog form state when closed without submitting - Replace raw Cancel + + + {retryMutation.isError && ( + + Failed + + )} + ); } diff --git a/web/src/components/runs/trigger-run-dialog.tsx b/web/src/components/runs/trigger-run-dialog.tsx index 42e22541..7ef40bd7 100644 --- a/web/src/components/runs/trigger-run-dialog.tsx +++ b/web/src/components/runs/trigger-run-dialog.tsx @@ -11,8 +11,9 @@ import { } from '@/components/ui/select.js'; import { trpc, trpcClient } from '@/lib/trpc.js'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { useState } from 'react'; +import { useCallback, useState } from 'react'; +// Keep in sync with AgentType in src/types/index.ts const agentTypes = [ 'briefing', 'planning', @@ -38,6 +39,25 @@ export function TriggerRunDialog({ open, onOpenChange }: TriggerRunDialogProps) const [prBranch, setPrBranch] = useState(''); const [model, setModel] = useState(''); + const resetForm = useCallback(() => { + setProjectId(''); + setAgentType(''); + setCardId(''); + setPrNumber(''); + setPrBranch(''); + setModel(''); + }, []); + + const handleOpenChange = useCallback( + (nextOpen: boolean) => { + onOpenChange(nextOpen); + if (!nextOpen) { + resetForm(); + } + }, + [onOpenChange, resetForm], + ); + const projectsQuery = useQuery(trpc.projects.list.queryOptions()); const runsQueryKey = trpc.runs.list.queryOptions({}).queryKey; @@ -54,19 +74,12 @@ export function TriggerRunDialog({ open, onOpenChange }: TriggerRunDialogProps) }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: runsQueryKey }); - onOpenChange(false); - // Reset form - setProjectId(''); - setAgentType(''); - setCardId(''); - setPrNumber(''); - setPrBranch(''); - setModel(''); + handleOpenChange(false); }, }); return ( - + Trigger Run @@ -80,6 +93,7 @@ export function TriggerRunDialog({ open, onOpenChange }: TriggerRunDialogProps) >
+ {/* Radix Select requires non-empty value; '_none' is used as a sentinel for unselected state */} setAgentType(v === '_none' ? '' : v)} @@ -161,13 +186,9 @@ export function TriggerRunDialog({ open, onOpenChange }: TriggerRunDialogProps)
- +