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>
135 lines
4.5 KiB
TypeScript
135 lines
4.5 KiB
TypeScript
'use client'
|
|
|
|
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'
|
|
import { ClarifyView } from './ClarifyView'
|
|
import { EvidenceSummary } from './EvidenceSummary'
|
|
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 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 endRef = useRef<HTMLDivElement>(null)
|
|
const latest = cases[cases.length - 1]
|
|
const active = cases.find((c) => c.id === activeCaseId) ?? latest
|
|
|
|
useEffect(() => {
|
|
if (!expanded) endRef.current?.scrollIntoView({ behavior: 'smooth' })
|
|
}, [cases.length, expanded])
|
|
|
|
const answer = active?.response?.mode === 'answer' ? active.response : null
|
|
const { highlightedId, cite } = useCitationHighlight(answer?.citations ?? [])
|
|
|
|
if (cases.length === 0) {
|
|
return (
|
|
<div className="flex-1 overflow-y-auto bg-gray-50">
|
|
<AdvisorEmptyState exampleQuestions={exampleQuestions} onExampleClick={onExample} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
if (!expanded) {
|
|
return (
|
|
<div className="min-h-0 flex-1 overflow-y-auto bg-gray-50">
|
|
{latest && <StickyQuestion question={latest.question} />}
|
|
<div className="space-y-4 p-4">
|
|
{cases.map((c, i) => (
|
|
<CaseView
|
|
key={c.id}
|
|
c={c}
|
|
busy={busy}
|
|
showQuestion={i !== cases.length - 1}
|
|
onSelectContext={(ctx) => onSelectContext(c.id, ctx)}
|
|
onRemove={() => onRemove(c.id)}
|
|
/>
|
|
))}
|
|
<div ref={endRef} />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const r = active?.response
|
|
return (
|
|
<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">
|
|
<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>
|
|
|
|
<main className="min-h-0 overflow-y-auto bg-gray-50 p-4">
|
|
{active?.status === 'loading' && <LoadingDots />}
|
|
{active?.status === 'error' && <ErrorBox msg={active.error} />}
|
|
{r?.mode === 'clarify' && (
|
|
<ClarifyView
|
|
response={r}
|
|
busy={busy}
|
|
onSelectContext={(ctx) => active && onSelectContext(active.id, ctx)}
|
|
/>
|
|
)}
|
|
{r?.mode === 'answer' && (
|
|
<div className="rounded-lg border border-gray-200 bg-white px-3 py-2">
|
|
<Markdown content={r.answer || ''} citations={cite} />
|
|
</div>
|
|
)}
|
|
</main>
|
|
|
|
<aside className="min-h-0 space-y-3 overflow-y-auto bg-gray-50 p-3">
|
|
{answer ? (
|
|
<>
|
|
<EvidencePane evidence={answer.evidence} highlightedId={highlightedId} />
|
|
<VisualEvidencePane items={answer.visual_evidence} />
|
|
<FootnotesPane footnotes={answer.footnotes} />
|
|
</>
|
|
) : (
|
|
<p className="px-1 text-[11px] text-gray-400">Evidence erscheint nach Auswahl eines Kontexts.</p>
|
|
)}
|
|
</aside>
|
|
</div>
|
|
)
|
|
}
|