feat(PBI-33): chat-kanaal UI + lint cleanup (#145)

* feat(PBI-33): chat-kanaal UI — IdeaTimeline merge + UserChatInput

Voltooit de UI-laag van PLAN_CHAT (gebruikersvragen over plan, Claude
antwoordt async). Backend (UserQuestion model, createUserQuestionAction,
SSE-handling, server-side prop-passing) was al aanwezig — alleen de
UI-koppeling ontbrak waardoor userQuestions ongebruikt bleven.

- IdeaDetailLayout geeft userQuestions/planMd/ideaId/isDemo door aan
  IdeaTimeline en telt user-questions mee in de tab-count
- IdeaTimeline mergt user-questions chronologisch met logs+questions,
  rendert ze met MessageCircle-icoon en pending/answered status, en
  toont onderaan UserChatInput wanneer plan_md aanwezig is
- UserChatInput nieuw component met textarea + verzend-knop dat
  createUserQuestionAction aanroept en op success router.refresh()
  triggert zodat SSE de pending-state oppikt
- useNotificationsRealtime: router toegevoegd aan useEffect-deps zodat
  router.refresh() op user_question/idea-job events werkt zonder
  stale-closure waarschuwing

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(lint): unused vars/imports + react-hook-form watch incompatibility

Resolves de overige lint-warnings van de gefaalde sprint-build die los
staan van PBI-33. Eslint-config staat unused vars/args toe als ze met
'_' prefixen, dus required interface-params krijgen een prefix terwijl
losse dode constantes/imports verwijderd worden.

- sprint-header: productId is required prop maar nog niet gebruikt
  → prefix _productId i.p.v. verwijderen (caller passeert het door)
- agent-throughput: STATUSES-constante was dood — verwijderd, queries
  gebruiken hardcoded status-velden in de perDay-loop
- claude-jobs: productAccessFilter en enforceUserRateLimit waren
  dode imports — verwijderd
- story-log.test: ongebruikte 'data' binding vervangen door bare
  await res.json() zodat de stream nog wel geconsumeerd wordt
- product-dialog: form.watch('auto_pr') vervangen door useWatch met
  control-prop — useWatch is veilig voor React Compiler memoization

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-05-07 16:04:53 +02:00 committed by GitHub
parent 5cb3abbd3d
commit 94f4f6ffd8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 251 additions and 99 deletions

View file

@ -129,7 +129,7 @@ describe('POST /api/stories/:id/log', () => {
const res = await postStoryLog( const res = await postStoryLog(
...makeRequest({ type: 'TEST_RESULT', content: 'Test gefaald.', status: 'FAILED' }) ...makeRequest({ type: 'TEST_RESULT', content: 'Test gefaald.', status: 'FAILED' })
) )
const data = await res.json() await res.json()
expect(res.status).toBe(201) expect(res.status).toBe(201)
expect(mockPrisma.storyLog.create).toHaveBeenCalledWith( expect(mockPrisma.storyLog.create).toHaveBeenCalledWith(

View file

@ -3,9 +3,7 @@
import { revalidatePath } from 'next/cache' import { revalidatePath } from 'next/cache'
import { prisma } from '@/lib/prisma' import { prisma } from '@/lib/prisma'
import { getSession } from '@/lib/auth' import { getSession } from '@/lib/auth'
import { productAccessFilter } from '@/lib/product-access'
import { ACTIVE_JOB_STATUSES, jobStatusToApi } from '@/lib/job-status' import { ACTIVE_JOB_STATUSES, jobStatusToApi } from '@/lib/job-status'
import { enforceUserRateLimit } from '@/lib/rate-limit'
type EnqueueResult = type EnqueueResult =
| { success: true; jobId: string } | { success: true; jobId: string }

View file

@ -1,7 +1,7 @@
'use client' 'use client'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useForm } from 'react-hook-form' import { useForm, useWatch } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod' import { zodResolver } from '@hookform/resolvers/zod'
import { toast } from 'sonner' import { toast } from 'sonner'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@ -150,7 +150,7 @@ export function ProductDialog(props: Props) {
} }
} }
const autoPr = form.watch('auto_pr') const autoPr = useWatch({ control: form.control, name: 'auto_pr' })
return ( return (
<> <>

View file

@ -210,9 +210,10 @@ export function IdeaDetailLayout({
{t.hasContent && !t.disabled && t.key !== 'idee' && t.key !== 'timeline' && ( {t.hasContent && !t.disabled && t.key !== 'idee' && t.key !== 'timeline' && (
<span className="ml-1 text-[10px] text-status-done"></span> <span className="ml-1 text-[10px] text-status-done"></span>
)} )}
{t.key === 'timeline' && (logs.length > 0 || questions.length > 0) ? ( {t.key === 'timeline' &&
(logs.length > 0 || questions.length > 0 || userQuestions.length > 0) ? (
<span className="ml-1.5 text-xs text-muted-foreground"> <span className="ml-1.5 text-xs text-muted-foreground">
({logs.length + questions.length}) ({logs.length + questions.length + userQuestions.length})
</span> </span>
) : null} ) : null}
</button> </button>
@ -249,7 +250,16 @@ export function IdeaDetailLayout({
ideaId={idea.id} ideaId={idea.id}
/> />
)} )}
{tab === 'timeline' && <IdeaTimeline logs={logs} questions={questions} />} {tab === 'timeline' && (
<IdeaTimeline
logs={logs}
questions={questions}
userQuestions={userQuestions}
planMd={plan_md}
ideaId={idea.id}
isDemo={isDemo}
/>
)}
{tab === 'sync' && showSync && syncData && <IdeaSyncTab data={syncData} />} {tab === 'sync' && showSync && syncData && <IdeaSyncTab data={syncData} />}
</div> </div>
) )

View file

@ -1,12 +1,13 @@
'use client' 'use client'
// IdeaTimeline — chronologische merge van IdeaLog + ClaudeQuestion entries. // IdeaTimeline — chronologische merge van IdeaLog + ClaudeQuestion + UserQuestion entries.
// Server-component zou ook kunnen, maar we mounten dit binnen de client-side // Server-component zou ook kunnen, maar we mounten dit binnen de client-side
// detail-layout dus client is simpler (geen rsc-boundary doorbreken). // detail-layout dus client is simpler (geen rsc-boundary doorbreken).
// //
// Iconen + kleur per log-type voor snelle herkenning. // Iconen + kleur per log-type voor snelle herkenning.
// Open questions krijgen een inline answer-form (M12 hotfix — zie // Open ClaudeQuestions krijgen een inline answer-form (M11).
// notifications-bell-pad in M11; voor idee-vragen blijft de bel buiten beeld). // PBI-33: UserQuestions tonen vraag + (indien beantwoord) Claude's antwoord.
// Onderaan: UserChatInput om nieuwe vraag te stellen (alleen als plan_md aanwezig is).
import { useState, useTransition } from 'react' import { useState, useTransition } from 'react'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
@ -15,6 +16,7 @@ import {
FileText, FileText,
HelpCircle, HelpCircle,
Lightbulb, Lightbulb,
MessageCircle,
RefreshCw, RefreshCw,
StickyNote, StickyNote,
Wrench, Wrench,
@ -24,6 +26,7 @@ import { toast } from 'sonner'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { answerQuestion } from '@/actions/questions' import { answerQuestion } from '@/actions/questions'
import { UserChatInput } from '@/components/ideas/user-chat-input'
import type { IdeaLogType } from '@prisma/client' import type { IdeaLogType } from '@prisma/client'
@ -45,9 +48,21 @@ export interface TimelineQuestion {
expires_at: string expires_at: string
} }
export interface TimelineUserQuestion {
id: string
question: string
answer: string | null
status: 'pending' | 'answered'
created_at: string
}
interface Props { interface Props {
logs: TimelineLog[] logs: TimelineLog[]
questions: TimelineQuestion[] questions: TimelineQuestion[]
userQuestions: TimelineUserQuestion[]
planMd: string | null
ideaId: string
isDemo?: boolean
} }
const LOG_ICON: Record<IdeaLogType, React.ReactNode> = { const LOG_ICON: Record<IdeaLogType, React.ReactNode> = {
@ -75,7 +90,19 @@ const QUESTION_STATUS_LABEL: Record<TimelineQuestion['status'], string> = {
expired: 'Verlopen', expired: 'Verlopen',
} }
export function IdeaTimeline({ logs, questions }: Props) { const USER_QUESTION_STATUS_LABEL: Record<TimelineUserQuestion['status'], string> = {
pending: 'In behandeling',
answered: 'Beantwoord',
}
export function IdeaTimeline({
logs,
questions,
userQuestions,
planMd,
ideaId,
isDemo = false,
}: Props) {
const merged = [ const merged = [
...logs.map((l) => ({ ...logs.map((l) => ({
kind: 'log' as const, kind: 'log' as const,
@ -87,98 +114,143 @@ export function IdeaTimeline({ logs, questions }: Props) {
created_at: q.created_at, created_at: q.created_at,
data: q, data: q,
})), })),
...userQuestions.map((uq) => ({
kind: 'user_question' as const,
created_at: uq.created_at,
data: uq,
})),
].sort((a, b) => (a.created_at < b.created_at ? 1 : -1)) ].sort((a, b) => (a.created_at < b.created_at ? 1 : -1))
if (merged.length === 0) { const showChatInput = planMd !== null
return (
<p className="text-sm text-muted-foreground py-8 text-center italic">
Nog geen activiteit op dit idee.
</p>
)
}
return ( return (
<ol className="border-l-2 border-input pl-4 space-y-3 ml-2"> <div className="space-y-4">
{merged.map((entry, i) => { {merged.length === 0 ? (
// Expliciete locale + format om SSR/CSR hydration-mismatch te voorkomen <p className="text-sm text-muted-foreground py-8 text-center italic">
// (server-locale verschilde van browser-locale). Nog geen activiteit op dit idee.
const time = new Date(entry.created_at).toLocaleString('nl-NL', { </p>
dateStyle: 'short', ) : (
timeStyle: 'short', <ol className="border-l-2 border-input pl-4 space-y-3 ml-2">
}) {merged.map((entry, i) => {
// Expliciete locale + format om SSR/CSR hydration-mismatch te voorkomen
// (server-locale verschilde van browser-locale).
const time = new Date(entry.created_at).toLocaleString('nl-NL', {
dateStyle: 'short',
timeStyle: 'short',
})
if (entry.kind === 'log') { if (entry.kind === 'log') {
const type = entry.data.type as IdeaLogType const type = entry.data.type as IdeaLogType
return ( return (
<li key={`l-${entry.data.id}`} className="relative"> <li key={`l-${entry.data.id}`} className="relative">
<span className="absolute -left-[26px] top-1 flex size-5 items-center justify-center rounded-full bg-surface-container text-muted-foreground"> <span className="absolute -left-[26px] top-1 flex size-5 items-center justify-center rounded-full bg-surface-container text-muted-foreground">
{LOG_ICON[type] ?? <StickyNote className="size-4" />} {LOG_ICON[type] ?? <StickyNote className="size-4" />}
</span>
<div className="rounded-md border border-input bg-surface-container p-3 space-y-1">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-medium uppercase tracking-wide">
{LOG_LABEL[type] ?? type}
</span> </span>
<span>·</span> <div className="rounded-md border border-input bg-surface-container p-3 space-y-1">
<time>{time}</time> <div className="flex items-center gap-2 text-xs text-muted-foreground">
</div> <span className="font-medium uppercase tracking-wide">
<p className="text-sm whitespace-pre-wrap">{entry.data.content}</p> {LOG_LABEL[type] ?? type}
{entry.data.metadata != null &&
typeof entry.data.metadata === 'object' &&
Object.keys(entry.data.metadata as object).length > 0 ? (
<details className="text-xs text-muted-foreground">
<summary className="cursor-pointer">metadata</summary>
<pre className="mt-1 whitespace-pre-wrap font-mono text-[10px]">
{JSON.stringify(entry.data.metadata, null, 2)}
</pre>
</details>
) : null}
</div>
</li>
)
}
const q = entry.data
return (
<li key={`q-${q.id}-${i}`} className="relative">
<span className="absolute -left-[26px] top-1 flex size-5 items-center justify-center rounded-full bg-surface-container text-status-review">
<HelpCircle className="size-4" />
</span>
<div className="rounded-md border border-input bg-surface-container p-3 space-y-2">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-medium uppercase tracking-wide">Vraag</span>
<span>·</span>
<span>{QUESTION_STATUS_LABEL[q.status]}</span>
<span>·</span>
<time>{time}</time>
</div>
<p className="text-sm">{q.question}</p>
{q.status === 'open' ? (
<AnswerForm questionId={q.id} options={q.options} />
) : (
<>
{q.options && q.options.length > 0 ? (
<ul className="text-xs text-muted-foreground list-disc list-inside">
{q.options.map((o, ii) => (
<li key={ii}>{o}</li>
))}
</ul>
) : null}
{q.answer ? (
<p className="text-sm border-l-2 border-primary pl-2 text-foreground">
<span className="text-xs font-medium uppercase tracking-wide text-primary mr-2">
Antwoord
</span> </span>
{q.answer} <span>·</span>
<time>{time}</time>
</div>
<p className="text-sm whitespace-pre-wrap">{entry.data.content}</p>
{entry.data.metadata != null &&
typeof entry.data.metadata === 'object' &&
Object.keys(entry.data.metadata as object).length > 0 ? (
<details className="text-xs text-muted-foreground">
<summary className="cursor-pointer">metadata</summary>
<pre className="mt-1 whitespace-pre-wrap font-mono text-[10px]">
{JSON.stringify(entry.data.metadata, null, 2)}
</pre>
</details>
) : null}
</div>
</li>
)
}
if (entry.kind === 'question') {
const q = entry.data
return (
<li key={`q-${q.id}-${i}`} className="relative">
<span className="absolute -left-[26px] top-1 flex size-5 items-center justify-center rounded-full bg-surface-container text-status-review">
<HelpCircle className="size-4" />
</span>
<div className="rounded-md border border-input bg-surface-container p-3 space-y-2">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-medium uppercase tracking-wide">Vraag</span>
<span>·</span>
<span>{QUESTION_STATUS_LABEL[q.status]}</span>
<span>·</span>
<time>{time}</time>
</div>
<p className="text-sm">{q.question}</p>
{q.status === 'open' ? (
<AnswerForm questionId={q.id} options={q.options} />
) : (
<>
{q.options && q.options.length > 0 ? (
<ul className="text-xs text-muted-foreground list-disc list-inside">
{q.options.map((o, ii) => (
<li key={ii}>{o}</li>
))}
</ul>
) : null}
{q.answer ? (
<p className="text-sm border-l-2 border-primary pl-2 text-foreground">
<span className="text-xs font-medium uppercase tracking-wide text-primary mr-2">
Antwoord
</span>
{q.answer}
</p>
) : null}
</>
)}
</div>
</li>
)
}
// user_question — gebruiker stelt vraag aan Claude (PBI-33 PLAN_CHAT)
const uq = entry.data
return (
<li key={`uq-${uq.id}`} className="relative">
<span className="absolute -left-[26px] top-1 flex size-5 items-center justify-center rounded-full bg-surface-container text-primary">
<MessageCircle className="size-4" />
</span>
<div className="rounded-md border border-input bg-surface-container p-3 space-y-2">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span className="font-medium uppercase tracking-wide">Jouw vraag</span>
<span>·</span>
<span>{USER_QUESTION_STATUS_LABEL[uq.status]}</span>
<span>·</span>
<time>{time}</time>
</div>
<p className="text-sm whitespace-pre-wrap">{uq.question}</p>
{uq.status === 'pending' ? (
<p className="text-xs text-muted-foreground italic">
Claude denkt na
</p> </p>
) : uq.answer ? (
<div className="border-l-2 border-primary pl-2">
<span className="text-xs font-medium uppercase tracking-wide text-primary">
Antwoord van Claude
</span>
<p className="text-sm whitespace-pre-wrap text-foreground mt-1">
{uq.answer}
</p>
</div>
) : null} ) : null}
</> </div>
)} </li>
</div> )
</li> })}
) </ol>
})} )}
</ol>
{showChatInput && <UserChatInput ideaId={ideaId} isDemo={isDemo} />}
</div>
) )
} }

View file

@ -0,0 +1,74 @@
'use client'
import { useState, useTransition } from 'react'
import { useRouter } from 'next/navigation'
import { Send } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { createUserQuestionAction } from '@/actions/user-questions'
interface Props {
ideaId: string
isDemo?: boolean
}
export function UserChatInput({ ideaId, isDemo = false }: Props) {
const router = useRouter()
const [text, setText] = useState('')
const [pending, startTransition] = useTransition()
function submit() {
const trimmed = text.trim()
if (!trimmed) {
toast.error('Vraag mag niet leeg zijn')
return
}
startTransition(async () => {
const r = await createUserQuestionAction(ideaId, trimmed)
if ('error' in r) {
toast.error(r.error)
return
}
toast.success('Vraag verzonden — Claude gaat ermee aan de slag.')
setText('')
router.refresh()
})
}
if (isDemo) {
return (
<div className="rounded-md border border-input bg-surface-container p-3">
<p className="text-xs text-muted-foreground italic">
Demo-modus: vragen stellen is niet beschikbaar.
</p>
</div>
)
}
return (
<div className="space-y-2 rounded-md border border-input bg-surface-container p-3">
<label className="text-xs font-medium text-muted-foreground">
Stel een vraag over dit plan
</label>
<Textarea
value={text}
onChange={(e) => setText(e.target.value)}
rows={3}
placeholder="Bijv. Waarom is gekozen voor X in plaats van Y?"
disabled={pending}
/>
<div className="flex justify-end">
<Button
size="sm"
disabled={pending || !text.trim()}
onClick={submit}
>
<Send className="size-4" />
{pending ? 'Bezig…' : 'Verzend'}
</Button>
</div>
</div>
)
}

View file

@ -61,7 +61,7 @@ function toDateInputValue(d: Date | null) {
return d.toISOString().slice(0, 10) return d.toISOString().slice(0, 10)
} }
export function SprintHeader({ productId, productName, sprint, isDemo, sprintStories }: SprintHeaderProps) { export function SprintHeader({ productId: _productId, productName, sprint, isDemo, sprintStories }: SprintHeaderProps) {
const [editingGoal, setEditingGoal] = useState(false) const [editingGoal, setEditingGoal] = useState(false)
const [editingDates, setEditingDates] = useState(false) const [editingDates, setEditingDates] = useState(false)
const [completeOpen, setCompleteOpen] = useState(false) const [completeOpen, setCompleteOpen] = useState(false)

View file

@ -21,8 +21,6 @@ export interface JobsPerDayResult {
kpi: ThroughputKpi kpi: ThroughputKpi
} }
const STATUSES = ['queued', 'claimed', 'running', 'done', 'failed', 'cancelled'] as const
type RawDayRow = { day: Date; status: string; count: bigint } type RawDayRow = { day: Date; status: string; count: bigint }
type RawKpiRow = { today_count: bigint; done_7d: bigint; terminal_7d: bigint; avg_seconds: number | null } type RawKpiRow = { today_count: bigint; done_7d: bigint; terminal_7d: bigint; avg_seconds: number | null }

View file

@ -208,5 +208,5 @@ export function useNotificationsRealtime() {
document.removeEventListener('visibilitychange', onVisibilityChange) document.removeEventListener('visibilitychange', onVisibilityChange)
close() close()
} }
}, []) }, [router])
} }