From f08574357b9e3513e50988a6803b92fda9337a47 Mon Sep 17 00:00:00 2001 From: janpeter visser Date: Fri, 1 May 2026 13:32:35 +0200 Subject: [PATCH] =?UTF-8?q?feat(M13):=20UI=20=E2=80=94=20'Open=20PR'=20lin?= =?UTF-8?q?k=20on=20DONE-card;=20pr=5Furl=20in=20JobState=20+=20SSE=20+=20?= =?UTF-8?q?task-dialog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../solo/task-detail-dialog.test.tsx | 48 +++++++++++++++++++ app/api/realtime/solo/route.ts | 4 +- components/solo/task-detail-dialog.tsx | 12 ++++- stores/solo-store.ts | 7 +-- 4 files changed, 66 insertions(+), 5 deletions(-) diff --git a/__tests__/components/solo/task-detail-dialog.test.tsx b/__tests__/components/solo/task-detail-dialog.test.tsx index a1ef88b..0a6d34f 100644 --- a/__tests__/components/solo/task-detail-dialog.test.tsx +++ b/__tests__/components/solo/task-detail-dialog.test.tsx @@ -132,6 +132,54 @@ describe('TaskDetailDialog — verify_result display', () => { }) }) +describe('TaskDetailDialog — PR link display', () => { + beforeEach(() => { + vi.clearAllMocks() + useSoloStore.setState({ + tasks: { 'task-1': baseTask }, + claudeJobsByTaskId: {}, + connectedWorkers: 0, + }) + global.fetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) }) + }) + + it('shows "Open PR" link when pr_url is set', () => { + useSoloStore.setState({ + claudeJobsByTaskId: { + 'task-1': { + job_id: 'j1', + task_id: 'task-1', + status: 'done', + branch: 'feat/job-abc', + pushed_at: '2026-01-01T00:00:00Z', + pr_url: 'https://github.com/org/repo/pull/42', + }, + }, + }) + render() + const link = screen.getByRole('link', { name: /Open PR/i }) + expect(link).toHaveAttribute('href', 'https://github.com/org/repo/pull/42') + }) + + it('shows "Open op GitHub" branch link when pushed_at is set but no pr_url', () => { + useSoloStore.setState({ + claudeJobsByTaskId: { + 'task-1': { + job_id: 'j1', + task_id: 'task-1', + status: 'done', + branch: 'feat/job-abc', + pushed_at: '2026-01-01T00:00:00Z', + }, + }, + }) + render() + expect(screen.queryByText(/Open PR/)).toBeNull() + const link = screen.getByRole('link', { name: /Open op GitHub/i }) + expect(link).toHaveAttribute('href', expect.stringContaining('feat/job-abc')) + }) +}) + describe('TaskDetailDialog — verify_only checkbox', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/app/api/realtime/solo/route.ts b/app/api/realtime/solo/route.ts index 575ea6c..112e0cc 100644 --- a/app/api/realtime/solo/route.ts +++ b/app/api/realtime/solo/route.ts @@ -46,6 +46,7 @@ type JobPayload = { status: string branch?: string pushed_at?: string + pr_url?: string verify_result?: string summary?: string error?: string @@ -260,7 +261,7 @@ async function prisma_jobs_findActive(userId: string, productId: string) { ], }, select: { - id: true, task_id: true, status: true, branch: true, pushed_at: true, verify_result: true, summary: true, error: true, + id: true, task_id: true, status: true, branch: true, pushed_at: true, pr_url: true, verify_result: true, summary: true, error: true, }, orderBy: { created_at: 'asc' }, }) @@ -270,6 +271,7 @@ async function prisma_jobs_findActive(userId: string, productId: string) { status: jobStatusToApi(j.status), branch: j.branch ?? undefined, pushed_at: j.pushed_at?.toISOString() ?? undefined, + pr_url: j.pr_url ?? undefined, verify_result: j.verify_result?.toLowerCase() as import('@/stores/solo-store').VerifyResultApi | undefined, summary: j.summary ?? undefined, error: j.error ?? undefined, diff --git a/components/solo/task-detail-dialog.tsx b/components/solo/task-detail-dialog.tsx index 6da9f74..953c614 100644 --- a/components/solo/task-detail-dialog.tsx +++ b/components/solo/task-detail-dialog.tsx @@ -269,7 +269,17 @@ function TaskDetailContent({ task, productId, isDemo, repoUrl, onClose }: TaskDe {job?.status === 'done' && ( Klaar{job.branch && !job.pushed_at ? ` — branch ${job.branch}` : ''} - {job.pushed_at && job.branch && repoUrl && ( + {job.pr_url && ( + + Open PR + + )} + {!job.pr_url && job.pushed_at && job.branch && repoUrl && ( ((set, get) => ({ return } if (event.type === 'claude_job_status') { - const { status, branch, pushed_at, verify_result, summary, error } = event + const { status, branch, pushed_at, pr_url, verify_result, summary, error } = event if (status === 'cancelled') { set((s) => { const next = { ...s.claudeJobsByTaskId } @@ -166,7 +167,7 @@ export const useSoloStore = create((set, get) => ({ set((s) => ({ claudeJobsByTaskId: { ...s.claudeJobsByTaskId, - [task_id]: { job_id, task_id, status, branch, pushed_at, verify_result, summary, error }, + [task_id]: { job_id, task_id, status, branch, pushed_at, pr_url, verify_result, summary, error }, }, })) }