From 94f4f6ffd855d49539504a0fd813faf773d938b3 Mon Sep 17 00:00:00 2001 From: Janpeter Visser <30029041+madhura68@users.noreply.github.com> Date: Thu, 7 May 2026 16:04:53 +0200 Subject: [PATCH] feat(PBI-33): chat-kanaal UI + lint cleanup (#145) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- __tests__/api/story-log.test.ts | 2 +- actions/claude-jobs.ts | 2 - components/dialogs/product-dialog.tsx | 4 +- components/ideas/idea-detail-layout.tsx | 16 +- components/ideas/idea-timeline.tsx | 246 +++++++++++++-------- components/ideas/user-chat-input.tsx | 74 +++++++ components/sprint/sprint-header.tsx | 2 +- lib/insights/agent-throughput.ts | 2 - lib/realtime/use-notifications-realtime.ts | 2 +- 9 files changed, 251 insertions(+), 99 deletions(-) create mode 100644 components/ideas/user-chat-input.tsx diff --git a/__tests__/api/story-log.test.ts b/__tests__/api/story-log.test.ts index 2ba3025..0a9b5df 100644 --- a/__tests__/api/story-log.test.ts +++ b/__tests__/api/story-log.test.ts @@ -129,7 +129,7 @@ describe('POST /api/stories/:id/log', () => { const res = await postStoryLog( ...makeRequest({ type: 'TEST_RESULT', content: 'Test gefaald.', status: 'FAILED' }) ) - const data = await res.json() + await res.json() expect(res.status).toBe(201) expect(mockPrisma.storyLog.create).toHaveBeenCalledWith( diff --git a/actions/claude-jobs.ts b/actions/claude-jobs.ts index 75b15fa..12fa3e9 100644 --- a/actions/claude-jobs.ts +++ b/actions/claude-jobs.ts @@ -3,9 +3,7 @@ import { revalidatePath } from 'next/cache' import { prisma } from '@/lib/prisma' import { getSession } from '@/lib/auth' -import { productAccessFilter } from '@/lib/product-access' import { ACTIVE_JOB_STATUSES, jobStatusToApi } from '@/lib/job-status' -import { enforceUserRateLimit } from '@/lib/rate-limit' type EnqueueResult = | { success: true; jobId: string } diff --git a/components/dialogs/product-dialog.tsx b/components/dialogs/product-dialog.tsx index a0a9797..a478fb9 100644 --- a/components/dialogs/product-dialog.tsx +++ b/components/dialogs/product-dialog.tsx @@ -1,7 +1,7 @@ 'use client' 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 { toast } from 'sonner' 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 ( <> diff --git a/components/ideas/idea-detail-layout.tsx b/components/ideas/idea-detail-layout.tsx index e8e3916..adbb857 100644 --- a/components/ideas/idea-detail-layout.tsx +++ b/components/ideas/idea-detail-layout.tsx @@ -210,9 +210,10 @@ export function IdeaDetailLayout({ {t.hasContent && !t.disabled && t.key !== 'idee' && t.key !== 'timeline' && ( )} - {t.key === 'timeline' && (logs.length > 0 || questions.length > 0) ? ( + {t.key === 'timeline' && + (logs.length > 0 || questions.length > 0 || userQuestions.length > 0) ? ( - ({logs.length + questions.length}) + ({logs.length + questions.length + userQuestions.length}) ) : null} @@ -249,7 +250,16 @@ export function IdeaDetailLayout({ ideaId={idea.id} /> )} - {tab === 'timeline' && } + {tab === 'timeline' && ( + + )} {tab === 'sync' && showSync && syncData && } ) diff --git a/components/ideas/idea-timeline.tsx b/components/ideas/idea-timeline.tsx index 133e176..c6265d6 100644 --- a/components/ideas/idea-timeline.tsx +++ b/components/ideas/idea-timeline.tsx @@ -1,12 +1,13 @@ '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 // detail-layout dus client is simpler (geen rsc-boundary doorbreken). // // Iconen + kleur per log-type voor snelle herkenning. -// Open questions krijgen een inline answer-form (M12 hotfix — zie -// notifications-bell-pad in M11; voor idee-vragen blijft de bel buiten beeld). +// Open ClaudeQuestions krijgen een inline answer-form (M11). +// 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 { useRouter } from 'next/navigation' @@ -15,6 +16,7 @@ import { FileText, HelpCircle, Lightbulb, + MessageCircle, RefreshCw, StickyNote, Wrench, @@ -24,6 +26,7 @@ import { toast } from 'sonner' import { Button } from '@/components/ui/button' import { Textarea } from '@/components/ui/textarea' import { answerQuestion } from '@/actions/questions' +import { UserChatInput } from '@/components/ideas/user-chat-input' import type { IdeaLogType } from '@prisma/client' @@ -45,9 +48,21 @@ export interface TimelineQuestion { expires_at: string } +export interface TimelineUserQuestion { + id: string + question: string + answer: string | null + status: 'pending' | 'answered' + created_at: string +} + interface Props { logs: TimelineLog[] questions: TimelineQuestion[] + userQuestions: TimelineUserQuestion[] + planMd: string | null + ideaId: string + isDemo?: boolean } const LOG_ICON: Record = { @@ -75,7 +90,19 @@ const QUESTION_STATUS_LABEL: Record = { expired: 'Verlopen', } -export function IdeaTimeline({ logs, questions }: Props) { +const USER_QUESTION_STATUS_LABEL: Record = { + pending: 'In behandeling', + answered: 'Beantwoord', +} + +export function IdeaTimeline({ + logs, + questions, + userQuestions, + planMd, + ideaId, + isDemo = false, +}: Props) { const merged = [ ...logs.map((l) => ({ kind: 'log' as const, @@ -87,98 +114,143 @@ export function IdeaTimeline({ logs, questions }: Props) { created_at: q.created_at, 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)) - if (merged.length === 0) { - return ( -

- Nog geen activiteit op dit idee. -

- ) - } + const showChatInput = planMd !== null return ( -
    - {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', - }) +
    + {merged.length === 0 ? ( +

    + Nog geen activiteit op dit idee. +

    + ) : ( +
      + {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') { - const type = entry.data.type as IdeaLogType - return ( -
    1. - - {LOG_ICON[type] ?? } - -
      -
      - - {LOG_LABEL[type] ?? type} + if (entry.kind === 'log') { + const type = entry.data.type as IdeaLogType + return ( +
    2. + + {LOG_ICON[type] ?? } - · - -
    -

    {entry.data.content}

    - {entry.data.metadata != null && - typeof entry.data.metadata === 'object' && - Object.keys(entry.data.metadata as object).length > 0 ? ( -
    - metadata -
    -                      {JSON.stringify(entry.data.metadata, null, 2)}
    -                    
    -
    - ) : null} - - - ) - } - - const q = entry.data - return ( -
  1. - - - -
    -
    - Vraag - · - {QUESTION_STATUS_LABEL[q.status]} - · - -
    -

    {q.question}

    - {q.status === 'open' ? ( - - ) : ( - <> - {q.options && q.options.length > 0 ? ( -
      - {q.options.map((o, ii) => ( -
    • {o}
    • - ))} -
    - ) : null} - {q.answer ? ( -

    - - Antwoord +

    +
    + + {LOG_LABEL[type] ?? type} - {q.answer} + · + +
    +

    {entry.data.content}

    + {entry.data.metadata != null && + typeof entry.data.metadata === 'object' && + Object.keys(entry.data.metadata as object).length > 0 ? ( +
    + metadata +
    +                          {JSON.stringify(entry.data.metadata, null, 2)}
    +                        
    +
    + ) : null} +
    +
  2. + ) + } + + if (entry.kind === 'question') { + const q = entry.data + return ( +
  3. + + + +
    +
    + Vraag + · + {QUESTION_STATUS_LABEL[q.status]} + · + +
    +

    {q.question}

    + {q.status === 'open' ? ( + + ) : ( + <> + {q.options && q.options.length > 0 ? ( +
      + {q.options.map((o, ii) => ( +
    • {o}
    • + ))} +
    + ) : null} + {q.answer ? ( +

    + + Antwoord + + {q.answer} +

    + ) : null} + + )} +
    +
  4. + ) + } + + // user_question — gebruiker stelt vraag aan Claude (PBI-33 PLAN_CHAT) + const uq = entry.data + return ( +
  5. + + + +
    +
    + Jouw vraag + · + {USER_QUESTION_STATUS_LABEL[uq.status]} + · + +
    +

    {uq.question}

    + {uq.status === 'pending' ? ( +

    + Claude denkt na…

    + ) : uq.answer ? ( +
    + + Antwoord van Claude + +

    + {uq.answer} +

    +
    ) : null} - - )} -
    -
  6. - ) - })} -
+ + + ) + })} + + )} + + {showChatInput && } + ) } diff --git a/components/ideas/user-chat-input.tsx b/components/ideas/user-chat-input.tsx new file mode 100644 index 0000000..cbdb4bd --- /dev/null +++ b/components/ideas/user-chat-input.tsx @@ -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 ( +
+

+ Demo-modus: vragen stellen is niet beschikbaar. +

+
+ ) + } + + return ( +
+ +