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 },
},
}))
}