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
@@ -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' && (