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