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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user