e8ea179228
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>
140 lines
5.4 KiB
TypeScript
140 lines
5.4 KiB
TypeScript
'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>
|
|
)
|
|
}
|