feat(advisor): topic threads, per-question delete/copy, fullscreen

Adds case management to the Compliance Advisor widget.

- topic threads: cases group into threads; the left menu shows each
  thread's first question as the Thema with expandable follow-ups.
  Send = follow-up to the active thread (carries the thread's prior Q&A
  as history for contextual answers); "+" starts a new topic.
- delete: a trash action per question (menu + stacked view).
- copy: single Q&A (question + answer + evidence + footnotes) or a whole
  thread, as Markdown to the clipboard (pure formatters in copy.ts).
- fullscreen: compact -> panel -> fullscreen view.
- route.ts consumes an optional bounded `history` so follow-ups are
  contextual for both the widget and the workspace consumer.

Tests: copy formatter unit tests + Playwright specs (threads/new-topic,
delete, fullscreen, copy affordance). No deploy.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-07-01 18:51:17 +02:00
parent a9b04e5286
commit e8ea179228
11 changed files with 611 additions and 71 deletions
@@ -75,12 +75,28 @@ function answerSystem(
return s
}
// Prior thread turns for contextual follow-ups. Validated + bounded (last 8 turns ~ 4 Q&A).
function parseHistory(raw: unknown): ChatMessage[] {
if (!Array.isArray(raw)) return []
const turns: ChatMessage[] = []
for (const t of raw) {
if (!t || typeof t !== 'object') continue
const role = (t as { role?: unknown }).role
const content = (t as { content?: unknown }).content
if ((role === 'user' || role === 'assistant') && typeof content === 'string' && content.trim()) {
turns.push({ role, content })
}
}
return turns.slice(-8)
}
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const question = String(body.question ?? body.message ?? '').trim()
const context: string | null = body.context ?? null
const audience = typeof body.audience === 'string' ? body.audience.trim() : ''
const history = parseHistory(body.history)
const country = (['DE', 'AT', 'CH', 'EU'] as const).includes(body.country)
? (body.country as Country)
: undefined
@@ -98,6 +114,7 @@ export async function POST(request: NextRequest) {
const legacySoul = await readSoulFile('compliance-advisor')
const legacyStream = await streamAdvisorAnswer([
{ role: 'system', content: answerSystem(legacySoul, country, numberedEvidenceForPrompt(legacyEvidence), false, audience) },
...history,
{ role: 'user', content: question },
])
if (!legacyStream) {
@@ -141,6 +158,7 @@ export async function POST(request: NextRequest) {
const soul = await readSoulFile('compliance-advisor')
const messages: ChatMessage[] = [
{ role: 'system', content: answerSystem(soul, country, numberedEvidenceForPrompt(evidence), true, audience) },
...history,
{ role: 'user', content: question },
]
const answer = await completeAdvisorAnswer(messages)
@@ -1,7 +1,20 @@
'use client'
import { useCallback, useState } from 'react'
import { Check, Loader2, Mail, Maximize2, MessagesSquare, Minimize2, Send, Square, X } from 'lucide-react'
import {
Check,
Expand,
Loader2,
Mail,
Maximize2,
MessagesSquare,
Minimize2,
Plus,
Send,
Shrink,
Square,
X,
} from 'lucide-react'
import { EXAMPLE_QUESTIONS } from './advisor/EmptyState'
import { EvidenceWorkspace } from './advisor/EvidenceWorkspace'
import { useAdvisorCase } from './advisor/useAdvisorCase'
@@ -14,20 +27,29 @@ interface ComplianceAdvisorWidgetProps {
type Country = 'DE' | 'AT' | 'CH' | 'EU'
const COUNTRIES: Country[] = ['DE', 'AT', 'CH', 'EU']
type View = 'compact' | 'panel' | 'fullscreen'
const SIZE: Record<View, string> = {
compact: 'bottom-6 right-6 h-[560px] w-[420px] rounded-2xl',
panel: 'bottom-6 right-6 h-[85vh] w-[960px] rounded-2xl',
fullscreen: 'inset-0 h-full w-full',
}
/**
* Compliance Advisor — a floating Case Workspace on every SDK page.
* Compliance Advisor — a floating Case Workspace on every SDK page (compact / panel / fullscreen).
* Renders ONLY structured SDK data (clarify/answer contract); it never parses the answer text.
* See memory: advisor-evidence-workspace-no-parse, advisor-clarity-gate-contract.
*/
export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceAdvisorWidgetProps) {
const [isOpen, setIsOpen] = useState(false)
const [isExpanded, setIsExpanded] = useState(false)
const [view, setView] = useState<View>('compact')
const [inputValue, setInputValue] = useState('')
const [country, setCountry] = useState<Country>('DE')
const { cases, busy, ask, selectContext, stop } = useAdvisorCase({ currentStep, country })
const { cases, threads, busy, activeCaseId, ask, newTopic, selectContext, selectCase, remove, stop } =
useAdvisorCase({ currentStep, country })
const email = useAdvisorEmail(cases, country, currentStep)
const exampleQuestions = EXAMPLE_QUESTIONS[currentStep] || EXAMPLE_QUESTIONS.default
const expanded = view !== 'compact'
const submit = useCallback(
(q: string) => {
@@ -38,6 +60,15 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA
[busy, ask],
)
const submitNewTopic = useCallback(
(q: string) => {
if (!q.trim() || busy) return
setInputValue('')
newTopic(q)
},
[busy, newTopic],
)
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
@@ -57,13 +88,14 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA
)
}
const headRound = view === 'fullscreen' ? '' : 'rounded-t-2xl'
const footRound = view === 'fullscreen' ? '' : 'rounded-b-2xl'
return (
<div
className={`fixed bottom-6 right-6 z-50 flex max-h-screen flex-col rounded-2xl border border-gray-200 bg-white shadow-2xl transition-all duration-200 ${
isExpanded ? 'h-[85vh] w-[960px]' : 'h-[560px] w-[420px]'
}`}
className={`fixed z-50 flex max-h-screen flex-col border border-gray-200 bg-white shadow-2xl transition-all duration-200 ${SIZE[view]}`}
>
<div className="flex items-center justify-between rounded-t-2xl bg-gradient-to-r from-purple-600 to-indigo-600 px-4 py-3 text-white">
<div className={`flex items-center justify-between bg-gradient-to-r from-purple-600 to-indigo-600 px-4 py-3 text-white ${headRound}`}>
<div className="flex items-center gap-2">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-white/20">
<MessagesSquare className="h-5 w-5" />
@@ -97,12 +129,22 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA
{email.sent ? <Check className="h-5 w-5" /> : email.sending ? <Loader2 className="h-5 w-5 animate-spin" /> : <Mail className="h-5 w-5" />}
</button>
)}
{view !== 'fullscreen' && (
<button
onClick={() => setView((v) => (v === 'compact' ? 'panel' : 'compact'))}
className="text-white/80 transition-colors hover:text-white"
aria-label={view === 'compact' ? 'Vergroessern' : 'Verkleinern'}
>
{view === 'compact' ? <Maximize2 className="h-5 w-5" /> : <Minimize2 className="h-5 w-5" />}
</button>
)}
<button
onClick={() => setIsExpanded((v) => !v)}
onClick={() => setView((v) => (v === 'fullscreen' ? 'panel' : 'fullscreen'))}
className="text-white/80 transition-colors hover:text-white"
aria-label={isExpanded ? 'Verkleinern' : 'Vergroessern'}
aria-label={view === 'fullscreen' ? 'Vollbild verlassen' : 'Vollbild'}
title={view === 'fullscreen' ? 'Vollbild verlassen' : 'Vollbild'}
>
{isExpanded ? <Minimize2 className="h-5 w-5" /> : <Maximize2 className="h-5 w-5" />}
{view === 'fullscreen' ? <Shrink className="h-5 w-5" /> : <Expand className="h-5 w-5" />}
</button>
<button
onClick={() => setIsOpen(false)}
@@ -116,21 +158,25 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA
<EvidenceWorkspace
cases={cases}
expanded={isExpanded}
threads={threads}
expanded={expanded}
busy={busy}
activeCaseId={activeCaseId}
exampleQuestions={exampleQuestions}
onExample={submit}
onSelectContext={selectContext}
onSelectCase={selectCase}
onRemove={remove}
/>
<div className="rounded-b-2xl border-t border-gray-200 bg-white p-3">
<div className={`border-t border-gray-200 bg-white p-3 ${footRound}`}>
<div className="flex gap-2">
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={onKeyDown}
placeholder="Frage eingeben..."
placeholder={cases.length > 0 ? 'Folgefrage eingeben...' : 'Frage eingeben...'}
disabled={busy}
className="flex-1 rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-transparent focus:outline-none focus:ring-2 focus:ring-purple-500 disabled:opacity-50"
/>
@@ -139,13 +185,27 @@ export function ComplianceAdvisorWidget({ currentStep = 'default' }: ComplianceA
<Square className="h-5 w-5" />
</button>
) : (
<button
onClick={() => submit(inputValue)}
disabled={!inputValue.trim()}
className="rounded-lg bg-indigo-600 px-4 py-2 text-white transition-colors hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-50"
>
<Send className="h-5 w-5" />
</button>
<>
{cases.length > 0 && (
<button
onClick={() => submitNewTopic(inputValue)}
disabled={!inputValue.trim()}
className="rounded-lg border border-gray-300 px-3 py-2 text-gray-600 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50"
title="Als neues Thema stellen"
aria-label="Neues Thema"
>
<Plus className="h-5 w-5" />
</button>
)}
<button
onClick={() => submit(inputValue)}
disabled={!inputValue.trim()}
className="rounded-lg bg-indigo-600 px-4 py-2 text-white transition-colors hover:bg-indigo-700 disabled:cursor-not-allowed disabled:opacity-50"
title={cases.length > 0 ? 'Folgefrage senden' : 'Frage senden'}
>
<Send className="h-5 w-5" />
</button>
</>
)}
</div>
</div>
@@ -41,7 +41,7 @@ const answer: AdvisorResponse = {
}
function mk(response: AdvisorResponse): AdvisorCase {
return { id: 'case1', question: response.question, response, selectedContext: null, status: 'done' }
return { id: 'case1', threadId: 'thread1', question: response.question, response, selectedContext: null, status: 'done' }
}
describe('CaseView — clarify mode', () => {
@@ -1,6 +1,8 @@
'use client'
import { Check, Copy, Trash2 } from 'lucide-react'
import type { AdvisorResponse } from '@/lib/sdk/advisor/contract'
import { formatCaseForCopy } from '@/lib/sdk/advisor/copy'
import type { AdvisorCase } from './useAdvisorCase'
import { ClarifyView } from './ClarifyView'
import { EvidenceSummary } from './EvidenceSummary'
@@ -9,6 +11,7 @@ import { VisualEvidencePane } from './VisualEvidencePane'
import { FootnotesPane } from './FootnotesPane'
import { Markdown } from './Markdown'
import { useCitationHighlight } from './useCitationHighlight'
import { useClipboard } from './useClipboard'
export function LoadingDots() {
return (
@@ -50,20 +53,49 @@ export function CaseView({
onSelectContext,
busy,
showQuestion,
onRemove,
}: {
c: AdvisorCase
onSelectContext: (ctx: string) => void
busy: boolean
showQuestion?: boolean
onRemove?: () => void
}) {
const r = c.response
const { copiedKey, copy } = useClipboard()
return (
<div className="space-y-2 border-b border-gray-100 pb-4 last:border-0">
{showQuestion && (
<div className="text-xs text-gray-500">
<span className="font-medium text-gray-400">Frage:</span> {c.question}
<div className="group space-y-2 border-b border-gray-100 pb-4 last:border-0">
<div className="flex items-start justify-between gap-2">
{showQuestion ? (
<div className="min-w-0 flex-1 text-xs text-gray-500">
<span className="font-medium text-gray-400">Frage:</span> {c.question}
</div>
) : (
<span className="flex-1" />
)}
<div className="flex shrink-0 items-center gap-0.5 text-gray-400 opacity-0 transition-opacity group-hover:opacity-100">
<button
type="button"
title="Frage & Antwort kopieren"
aria-label="Frage & Antwort kopieren"
onClick={() => copy(c.id, formatCaseForCopy(c))}
className="rounded p-0.5 hover:bg-gray-100 hover:text-gray-700"
>
{copiedKey === c.id ? <Check className="h-3.5 w-3.5 text-green-600" /> : <Copy className="h-3.5 w-3.5" />}
</button>
{onRemove && (
<button
type="button"
title="Frage löschen"
aria-label="Frage löschen"
onClick={onRemove}
className="rounded p-0.5 hover:bg-gray-100 hover:text-gray-700"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
)}
</div>
)}
</div>
{c.status === 'loading' && <LoadingDots />}
{c.status === 'error' && <ErrorBox msg={c.error} />}
{r && r.mode === 'clarify' && (
@@ -1,7 +1,7 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import type { AdvisorCase } from './useAdvisorCase'
import { useEffect, useRef } from 'react'
import type { AdvisorCase, AdvisorThread } from './useAdvisorCase'
import { StickyQuestion } from './StickyQuestion'
import { AdvisorEmptyState } from './EmptyState'
import { CaseView, LoadingDots, ErrorBox } from './CaseView'
@@ -11,36 +11,41 @@ import { EvidencePane } from './EvidencePane'
import { VisualEvidencePane } from './VisualEvidencePane'
import { FootnotesPane } from './FootnotesPane'
import { Markdown } from './Markdown'
import { ThreadMenu } from './ThreadMenu'
import { useCitationHighlight } from './useCitationHighlight'
/**
* Advisor body as a series of CASES.
* - Narrow: stacked cases with a pinned last question.
* - Wide: 3-column Case Workspace — question+summary (left) | answer/clarify (center) | evidence (right).
* Advisor body as topic THREADS of cases.
* - Narrow: stacked cases with a pinned last question; per-case copy + delete.
* - Wide/fullscreen: 3-column Case Workspace — topic tree (left) | answer/clarify (center) | evidence (right).
*/
export function EvidenceWorkspace({
cases,
threads,
expanded,
busy,
activeCaseId,
exampleQuestions,
onExample,
onSelectContext,
onSelectCase,
onRemove,
}: {
cases: AdvisorCase[]
threads: AdvisorThread[]
expanded: boolean
busy: boolean
activeCaseId: string | null
exampleQuestions: string[]
onExample: (q: string) => void
onSelectContext: (caseId: string, ctx: string) => void
onSelectCase: (id: string) => void
onRemove: (id: string) => void
}) {
const [activeId, setActiveId] = useState<string | null>(null)
const endRef = useRef<HTMLDivElement>(null)
const latest = cases[cases.length - 1]
const active = cases.find((c) => c.id === activeId) ?? latest
const active = cases.find((c) => c.id === activeCaseId) ?? latest
useEffect(() => {
setActiveId(null)
}, [cases.length])
useEffect(() => {
if (!expanded) endRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [cases.length, expanded])
@@ -68,6 +73,7 @@ export function EvidenceWorkspace({
busy={busy}
showQuestion={i !== cases.length - 1}
onSelectContext={(ctx) => onSelectContext(c.id, ctx)}
onRemove={() => onRemove(c.id)}
/>
))}
<div ref={endRef} />
@@ -78,27 +84,19 @@ export function EvidenceWorkspace({
const r = active?.response
return (
<div className="grid min-h-0 flex-1 grid-cols-[220px_1fr_320px] divide-x divide-gray-200 overflow-hidden">
<div className="grid min-h-0 flex-1 grid-cols-[260px_1fr_320px] divide-x divide-gray-200 overflow-hidden">
<aside className="min-h-0 overflow-y-auto bg-indigo-50/40 p-3">
<div className="text-[10px] font-semibold uppercase tracking-wide text-indigo-400">Frage</div>
<div className="mb-3 text-sm font-medium text-gray-800">{active?.question}</div>
{answer && <EvidenceSummary response={answer} />}
{cases.length > 1 && (
<div className="mt-4">
<div className="mb-1 text-[10px] font-semibold uppercase tracking-wide text-gray-400">Verlauf</div>
<div className="space-y-1">
{cases.map((c) => (
<button
key={c.id}
onClick={() => setActiveId(c.id)}
className={`block w-full truncate rounded px-2 py-1 text-left text-[11px] ${
c.id === active?.id ? 'bg-indigo-100 text-indigo-800' : 'text-gray-600 hover:bg-gray-100'
}`}
>
{c.question}
</button>
))}
</div>
<ThreadMenu
threads={threads}
activeCaseId={active?.id ?? null}
onSelectCase={onSelectCase}
onRemove={onRemove}
/>
{active && (
<div className="mt-3 border-t border-indigo-100 pt-3">
<div className="text-[10px] font-semibold uppercase tracking-wide text-indigo-400">Aktive Frage</div>
<div className="mb-3 text-sm font-medium text-gray-800">{active.question}</div>
{answer && <EvidenceSummary response={answer} />}
</div>
)}
</aside>
@@ -0,0 +1,139 @@
'use client'
import { useState } from 'react'
import { Check, ChevronDown, ChevronRight, Copy, Files, Trash2 } from 'lucide-react'
import type { AdvisorThread } from './useAdvisorCase'
import { formatCaseForCopy, formatThreadForCopy } from '@/lib/sdk/advisor/copy'
import { useClipboard } from './useClipboard'
function IconBtn({
title,
onClick,
children,
}: {
title: string
onClick: () => void
children: React.ReactNode
}) {
return (
<button
type="button"
title={title}
aria-label={title}
onClick={(e) => {
e.stopPropagation()
onClick()
}}
className="rounded p-0.5 text-gray-400 transition-colors hover:bg-gray-200 hover:text-gray-700"
>
{children}
</button>
)
}
/**
* Left-menu topic tree: each thread's first question is the Thema; follow-ups nest underneath and
* expand/collapse. Per row: copy (single Q&A) + delete; per topic: copy the whole thread.
*/
export function ThreadMenu({
threads,
activeCaseId,
onSelectCase,
onRemove,
}: {
threads: AdvisorThread[]
activeCaseId: string | null
onSelectCase: (id: string) => void
onRemove: (id: string) => void
}) {
const { copiedKey, copy } = useClipboard()
const [collapsed, setCollapsed] = useState<Record<string, boolean>>({})
const ic = 'h-3.5 w-3.5'
return (
<div>
<div className="mb-1 text-[10px] font-semibold uppercase tracking-wide text-gray-400">Themen</div>
<div className="space-y-0.5">
{threads.map((t) => {
const first = t.cases[0]
const followups = t.cases.slice(1)
const open = !collapsed[t.id]
const activeInThread = t.cases.some((c) => c.id === activeCaseId)
return (
<div key={t.id}>
<div
className={`group flex items-center gap-1 rounded px-1.5 py-1 ${
first.id === activeCaseId ? 'bg-indigo-100' : activeInThread ? 'bg-indigo-50/60' : 'hover:bg-gray-100'
}`}
>
{followups.length > 0 ? (
<button
type="button"
onClick={() => setCollapsed((s) => ({ ...s, [t.id]: !s[t.id] }))}
className="text-gray-400 hover:text-gray-700"
aria-label={open ? 'Thema einklappen' : 'Thema aufklappen'}
>
{open ? <ChevronDown className={ic} /> : <ChevronRight className={ic} />}
</button>
) : (
<span className="w-3.5 shrink-0" />
)}
<button
type="button"
onClick={() => onSelectCase(first.id)}
title={first.question}
className={`min-w-0 flex-1 truncate text-left text-[12px] font-medium ${
first.id === activeCaseId ? 'text-indigo-800' : 'text-gray-700'
}`}
>
{first.question}
</button>
<div className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
<IconBtn title="Ganzes Thema kopieren" onClick={() => copy(`thread:${t.id}`, formatThreadForCopy(t.title, t.cases))}>
{copiedKey === `thread:${t.id}` ? <Check className={`${ic} text-green-600`} /> : <Files className={ic} />}
</IconBtn>
<IconBtn title="Diese Frage kopieren" onClick={() => copy(`case:${first.id}`, formatCaseForCopy(first))}>
{copiedKey === `case:${first.id}` ? <Check className={`${ic} text-green-600`} /> : <Copy className={ic} />}
</IconBtn>
<IconBtn title="Frage löschen" onClick={() => onRemove(first.id)}>
<Trash2 className={ic} />
</IconBtn>
</div>
</div>
{open &&
followups.map((c) => (
<div
key={c.id}
className={`group ml-4 flex items-center gap-1 rounded px-1.5 py-1 ${
c.id === activeCaseId ? 'bg-indigo-100' : 'hover:bg-gray-100'
}`}
>
<span className="shrink-0 text-gray-300"></span>
<button
type="button"
onClick={() => onSelectCase(c.id)}
title={c.question}
className={`min-w-0 flex-1 truncate text-left text-[11px] ${
c.id === activeCaseId ? 'text-indigo-800' : 'text-gray-600'
}`}
>
{c.question}
</button>
<div className="flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
<IconBtn title="Diese Frage kopieren" onClick={() => copy(`case:${c.id}`, formatCaseForCopy(c))}>
{copiedKey === `case:${c.id}` ? <Check className={`${ic} text-green-600`} /> : <Copy className={ic} />}
</IconBtn>
<IconBtn title="Frage löschen" onClick={() => onRemove(c.id)}>
<Trash2 className={ic} />
</IconBtn>
</div>
</div>
))}
</div>
)
})}
</div>
</div>
)
}
@@ -1,10 +1,11 @@
'use client'
import { useCallback, useRef, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { AdvisorResponse } from '@/lib/sdk/advisor/contract'
export interface AdvisorCase {
id: string
threadId: string
question: string
response: AdvisorResponse | null
selectedContext: string | null
@@ -12,34 +13,68 @@ export interface AdvisorCase {
error?: string
}
/** A topic: the first case's question is the title; follow-ups are the rest, in order. */
export interface AdvisorThread {
id: string
title: string
cases: AdvisorCase[]
}
interface HistoryTurn {
role: 'user' | 'assistant'
content: string
}
interface UseAdvisorCaseArgs {
currentStep: string
country: string
}
let counter = 0
const uid = (p: string) => `${p}-${Date.now()}-${counter++}`
/**
* Drives the Advisor as a series of CASES. Each ask posts {question, context?} and receives a
* structured AdvisorResponse (mode: clarify | answer) — no streaming, no answer-text parsing.
* selectContext() re-runs the same case scoped to a chosen domain (clarify -> answer).
* Drives the Advisor as topic THREADS of CASES. Each ask posts {question, context?, history} and
* receives a structured AdvisorResponse (clarify | answer) — no streaming, no answer-text parsing.
* A follow-up appends to the active thread (and carries the thread's prior Q&A as history);
* newTopic() starts a fresh thread. selectContext() re-runs a case scoped to a chosen domain.
*/
export function useAdvisorCase({ currentStep, country }: UseAdvisorCaseArgs) {
const [cases, setCases] = useState<AdvisorCase[]>([])
const [busy, setBusy] = useState(false)
const [activeCaseId, setActiveCaseId] = useState<string | null>(null)
const [activeThreadId, setActiveThreadId] = useState<string | null>(null)
const abortRef = useRef<AbortController | null>(null)
const patch = useCallback((id: string, p: Partial<AdvisorCase>) => {
setCases((prev) => prev.map((c) => (c.id === id ? { ...c, ...p } : c)))
}, [])
// Prior answered turns of a thread, up to (but excluding) `beforeId`, for contextual follow-ups.
const buildHistory = useCallback(
(threadId: string, beforeId?: string): HistoryTurn[] => {
const turns: HistoryTurn[] = []
for (const c of cases) {
if (c.threadId !== threadId) continue
if (beforeId && c.id === beforeId) break
const a = c.response?.answer ?? c.response?.general_answer
if (!a) continue
turns.push({ role: 'user', content: c.question }, { role: 'assistant', content: a })
}
return turns
},
[cases],
)
const run = useCallback(
async (id: string, question: string, context: string | null) => {
async (id: string, question: string, context: string | null, history: HistoryTurn[]) => {
setBusy(true)
abortRef.current = new AbortController()
try {
const res = await fetch('/api/sdk/compliance-advisor/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question, context, currentStep, country }),
body: JSON.stringify({ question, context, history, currentStep, country }),
signal: abortRef.current.signal,
})
if (!res.ok) {
@@ -65,33 +100,89 @@ export function useAdvisorCase({ currentStep, country }: UseAdvisorCaseArgs) {
)
const ask = useCallback(
(question: string) => {
(question: string, opts?: { newThread?: boolean }) => {
const q = question.trim()
if (!q || busy) return
const id = `case-${Date.now()}`
const startNew = opts?.newThread || !activeThreadId || cases.length === 0
const threadId = startNew ? uid('thread') : activeThreadId!
const id = uid('case')
const history = startNew ? [] : buildHistory(threadId)
setCases((prev) => [
...prev,
{ id, question: q, response: null, selectedContext: null, status: 'loading' },
{ id, threadId, question: q, response: null, selectedContext: null, status: 'loading' },
])
void run(id, q, null)
setActiveThreadId(threadId)
setActiveCaseId(id)
void run(id, q, null, history)
},
[busy, run],
[busy, activeThreadId, cases.length, buildHistory, run],
)
const newTopic = useCallback((question: string) => ask(question, { newThread: true }), [ask])
const selectContext = useCallback(
(id: string, context: string) => {
const c = cases.find((x) => x.id === id)
if (!c || busy) return
patch(id, { status: 'loading', selectedContext: context })
void run(id, c.question, context)
void run(id, c.question, context, buildHistory(c.threadId, id))
},
[cases, busy, run, patch],
[cases, busy, run, patch, buildHistory],
)
const remove = useCallback((id: string) => {
setCases((prev) => prev.filter((c) => c.id !== id))
}, [])
const selectCase = useCallback((id: string) => {
setActiveCaseId(id)
setCases((prev) => {
const c = prev.find((x) => x.id === id)
if (c) setActiveThreadId(c.threadId)
return prev
})
}, [])
const stop = useCallback(() => {
abortRef.current?.abort()
setBusy(false)
}, [])
return { cases, busy, ask, selectContext, stop }
// Keep the active selection valid after deletions.
useEffect(() => {
if (activeCaseId && !cases.some((c) => c.id === activeCaseId)) {
const last = cases[cases.length - 1] ?? null
setActiveCaseId(last?.id ?? null)
setActiveThreadId(last?.threadId ?? null)
}
}, [cases, activeCaseId])
const threads = useMemo<AdvisorThread[]>(() => {
const order: string[] = []
const byId = new Map<string, AdvisorThread>()
for (const c of cases) {
let t = byId.get(c.threadId)
if (!t) {
t = { id: c.threadId, title: c.question, cases: [] }
byId.set(c.threadId, t)
order.push(c.threadId)
}
t.cases.push(c)
}
return order.map((id) => byId.get(id)!)
}, [cases])
return {
cases,
threads,
busy,
activeCaseId,
activeThreadId,
ask,
newTopic,
selectContext,
selectCase,
remove,
stop,
}
}
@@ -0,0 +1,19 @@
'use client'
import { useCallback, useState } from 'react'
/** Writes text to the clipboard and flags the copied key briefly (for a check-mark affordance). */
export function useClipboard(resetMs = 1500) {
const [copiedKey, setCopiedKey] = useState<string | null>(null)
const copy = useCallback(
(key: string, text: string) => {
void navigator.clipboard?.writeText(text)
setCopiedKey(key)
window.setTimeout(() => setCopiedKey((k) => (k === key ? null : k)), resetMs)
},
[resetMs],
)
return { copiedKey, copy }
}
@@ -0,0 +1,70 @@
/**
* E2E: Compliance Advisor widget UX — topic threads, new-topic vs follow-up, delete, copy, fullscreen.
* Stubs the chat endpoint with an answer fixture so every ask yields a finished case.
*/
import { test, expect } from '../fixtures/sdk-fixtures'
const CHAT_ROUTE = '**/api/sdk/compliance-advisor/chat'
const openAdvisor = 'Compliance Advisor oeffnen'
const ANSWER = {
mode: 'answer',
question: '',
clarity: { is_underspecified: false, dominant_context: 'cyber', concentration: 0.9 },
general_answer: null,
answer: 'Musterantwort [1].',
scoped_query: null,
evidence: [{ evidence_id: 'e1', document: 'DSGVO', section: 'Art. 5', bindingness: 'binding' }],
citations: [{ citation_id: 'c1', number: 1, evidence_id: 'e1', document: 'DSGVO', section: 'Art. 5' }],
visual_evidence: [],
footnotes: [],
}
async function stub(page: import('@playwright/test').Page) {
await page.route(CHAT_ROUTE, (r) =>
r.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(ANSWER) }),
)
}
test('new topic creates a second thread; copy control + fullscreen available', async ({ sdkPage }) => {
await stub(sdkPage)
await sdkPage.getByRole('button', { name: openAdvisor }).click()
const input = sdkPage.getByPlaceholder('Frage eingeben...')
await input.fill('Erste Frage')
await input.press('Enter')
await expect(sdkPage.getByText(/Musterantwort/)).toBeVisible()
// expand -> the topic tree ("Themen") appears in the left menu
await sdkPage.getByRole('button', { name: 'Vergroessern' }).click()
await expect(sdkPage.getByText('Themen')).toBeVisible()
await expect(sdkPage.getByText('Erste Frage').first()).toBeVisible()
// a second, separate topic
await sdkPage.getByPlaceholder('Folgefrage eingeben...').fill('Zweites Thema')
await sdkPage.getByRole('button', { name: 'Neues Thema' }).click()
await expect(sdkPage.getByText('Zweites Thema').first()).toBeVisible()
await expect(sdkPage.getByText('Erste Frage').first()).toBeVisible()
// copy affordance + fullscreen toggle
await expect(sdkPage.getByRole('button', { name: 'Diese Frage kopieren' }).first()).toBeVisible()
await sdkPage.getByRole('button', { name: 'Vollbild' }).click()
await expect(sdkPage.getByRole('button', { name: 'Vollbild verlassen' })).toBeVisible()
})
test('delete removes a question from the thread', async ({ sdkPage }) => {
await stub(sdkPage)
await sdkPage.getByRole('button', { name: openAdvisor }).click()
const input = sdkPage.getByPlaceholder('Frage eingeben...')
await input.fill('Zu löschen')
await input.press('Enter')
await expect(sdkPage.getByText(/Musterantwort/)).toBeVisible()
await sdkPage.getByRole('button', { name: 'Vergroessern' }).click()
await expect(sdkPage.getByText('Zu löschen').first()).toBeVisible()
await sdkPage.getByRole('button', { name: 'Frage löschen' }).first().click()
await expect(sdkPage.getByText('Zu löschen')).toHaveCount(0)
})
@@ -0,0 +1,65 @@
import { describe, it, expect } from 'vitest'
import { formatCaseForCopy, formatThreadForCopy } from '../advisor/copy'
import type { AdvisorResponse } from '../advisor/contract'
const answer: AdvisorResponse = {
mode: 'answer',
question: 'CRA Meldefrist',
clarity: { is_underspecified: false, concentration: 0.9 },
general_answer: null,
answer: 'Unverzüglich melden [1].',
scoped_query: 'cyber',
evidence: [{ evidence_id: 'e1', document: 'CRA', section: 'Art. 14', paragraph: 'Abs. 1', url: 'https://x' }],
citations: [],
visual_evidence: [],
footnotes: [{ footnote_id: 'f1', ref: 'Fußnote 3', document: 'EDPB', section: 'Kap III', text: 'Detail' }],
}
const clarify: AdvisorResponse = {
mode: 'clarify',
question: 'Was ist PDCA?',
clarity: { is_underspecified: true, concentration: 0.3 },
general_answer: 'PDCA = Plan-Do-Check-Act.',
answer: null,
scoped_query: null,
evidence: [],
citations: [],
visual_evidence: [],
footnotes: [],
}
describe('formatCaseForCopy', () => {
it('includes question, answer, resolved evidence and footnotes', () => {
const s = formatCaseForCopy({ question: 'CRA Meldefrist', response: answer })
expect(s).toContain('### Frage\nCRA Meldefrist')
expect(s).toContain('### Antwort\nUnverzüglich melden [1].')
expect(s).toContain('### Belege')
expect(s).toContain('Cyber Resilience Act (CRA), Art. 14 Abs. 1 (https://x)')
expect(s).toContain('### Fußnoten')
expect(s).toContain('Fußnote 3 — EDPB / Kap III: Detail')
})
it('falls back to the general definition for a clarify case', () => {
const s = formatCaseForCopy({ question: 'Was ist PDCA?', response: clarify })
expect(s).toContain('### Antwort\nPDCA = Plan-Do-Check-Act.')
expect(s).not.toContain('### Belege')
})
it('handles a case without a response', () => {
const s = formatCaseForCopy({ question: 'offen', response: null })
expect(s).toContain('### Antwort\n(keine Antwort)')
})
})
describe('formatThreadForCopy', () => {
it('renders a title heading + every case separated by a rule', () => {
const s = formatThreadForCopy('CRA Meldefrist', [
{ question: 'CRA Meldefrist', response: answer },
{ question: 'Und für KMU?', response: clarify },
])
expect(s.startsWith('# CRA Meldefrist')).toBe(true)
expect(s).toContain('\n---\n')
expect(s).toContain('CRA Meldefrist')
expect(s).toContain('Und für KMU?')
})
})
+48
View File
@@ -0,0 +1,48 @@
// Pure formatters for "copy to clipboard": a single case (Q + answer + evidence) or a whole thread.
// No DOM/clipboard here (that is the caller's side effect) so this stays testable.
import type { AdvisorResponse, EvidenceUnit } from './contract'
import { resolveRegulation } from './regulation-display'
export interface CopyableCase {
question: string
response: AdvisorResponse | null
}
function evidenceLine(e: EvidenceUnit): string {
const { familyLabel } = resolveRegulation({ code: e.regulation_code || e.document, short: e.document })
const loc = [e.section, e.paragraph].filter(Boolean).join(' ')
const ref = [familyLabel, loc].filter(Boolean).join(', ')
return e.url ? `- ${ref} (${e.url})` : `- ${ref}`
}
/** One case as portable Markdown: question, answer (or general definition), and its evidence. */
export function formatCaseForCopy(c: CopyableCase): string {
const parts: string[] = [`### Frage`, c.question.trim()]
const r = c.response
const answer = (r?.answer || r?.general_answer || '').trim()
parts.push('', '### Antwort', answer || '(keine Antwort)')
if (r && r.evidence.length > 0) {
parts.push('', '### Belege', ...r.evidence.map(evidenceLine))
}
if (r && r.footnotes.length > 0) {
parts.push(
'',
'### Fußnoten',
...r.footnotes.map((f, i) => {
const head = f.ref || `Fußnote ${i + 1}`
const src = [f.document, f.section].filter(Boolean).join(' / ')
return `- ${[head, src].filter(Boolean).join(' — ')}${f.text ? `: ${f.text}` : ''}`
}),
)
}
return parts.join('\n')
}
/** A whole topic thread: a title heading followed by every case, separated by rules. */
export function formatThreadForCopy(title: string, cases: CopyableCase[]): string {
const header = `# ${title.trim() || 'Compliance-Advisor-Verlauf'}`
const body = cases.map(formatCaseForCopy).join('\n\n---\n\n')
return `${header}\n\n${body}`
}