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:
@@ -41,7 +41,7 @@ const answer: AdvisorResponse = {
|
||||
}
|
||||
|
||||
function mk(response: AdvisorResponse): AdvisorCase {
|
||||
return { id: 'case1', question: response.question, response, selectedContext: null, status: 'done' }
|
||||
return { id: 'case1', threadId: 'thread1', question: response.question, response, selectedContext: null, status: 'done' }
|
||||
}
|
||||
|
||||
describe('CaseView — clarify mode', () => {
|
||||
|
||||
@@ -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' && (
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { AdvisorCase } from './useAdvisorCase'
|
||||
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'
|
||||
@@ -11,36 +11,41 @@ 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 a series of CASES.
|
||||
* - Narrow: stacked cases with a pinned last question.
|
||||
* - Wide: 3-column Case Workspace — question+summary (left) | answer/clarify (center) | evidence (right).
|
||||
* 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 [activeId, setActiveId] = useState<string | null>(null)
|
||||
const endRef = useRef<HTMLDivElement>(null)
|
||||
const latest = cases[cases.length - 1]
|
||||
const active = cases.find((c) => c.id === activeId) ?? latest
|
||||
const active = cases.find((c) => c.id === activeCaseId) ?? latest
|
||||
|
||||
useEffect(() => {
|
||||
setActiveId(null)
|
||||
}, [cases.length])
|
||||
useEffect(() => {
|
||||
if (!expanded) endRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [cases.length, expanded])
|
||||
@@ -68,6 +73,7 @@ export function EvidenceWorkspace({
|
||||
busy={busy}
|
||||
showQuestion={i !== cases.length - 1}
|
||||
onSelectContext={(ctx) => onSelectContext(c.id, ctx)}
|
||||
onRemove={() => onRemove(c.id)}
|
||||
/>
|
||||
))}
|
||||
<div ref={endRef} />
|
||||
@@ -78,27 +84,19 @@ export function EvidenceWorkspace({
|
||||
|
||||
const r = active?.response
|
||||
return (
|
||||
<div className="grid min-h-0 flex-1 grid-cols-[220px_1fr_320px] divide-x divide-gray-200 overflow-hidden">
|
||||
<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">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wide text-indigo-400">Frage</div>
|
||||
<div className="mb-3 text-sm font-medium text-gray-800">{active?.question}</div>
|
||||
{answer && <EvidenceSummary response={answer} />}
|
||||
{cases.length > 1 && (
|
||||
<div className="mt-4">
|
||||
<div className="mb-1 text-[10px] font-semibold uppercase tracking-wide text-gray-400">Verlauf</div>
|
||||
<div className="space-y-1">
|
||||
{cases.map((c) => (
|
||||
<button
|
||||
key={c.id}
|
||||
onClick={() => setActiveId(c.id)}
|
||||
className={`block w-full truncate rounded px-2 py-1 text-left text-[11px] ${
|
||||
c.id === active?.id ? 'bg-indigo-100 text-indigo-800' : 'text-gray-600 hover:bg-gray-100'
|
||||
}`}
|
||||
>
|
||||
{c.question}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { AdvisorResponse } from '@/lib/sdk/advisor/contract'
|
||||
|
||||
export interface AdvisorCase {
|
||||
id: string
|
||||
threadId: string
|
||||
question: string
|
||||
response: AdvisorResponse | null
|
||||
selectedContext: string | null
|
||||
@@ -12,34 +13,68 @@ export interface AdvisorCase {
|
||||
error?: string
|
||||
}
|
||||
|
||||
/** A topic: the first case's question is the title; follow-ups are the rest, in order. */
|
||||
export interface AdvisorThread {
|
||||
id: string
|
||||
title: string
|
||||
cases: AdvisorCase[]
|
||||
}
|
||||
|
||||
interface HistoryTurn {
|
||||
role: 'user' | 'assistant'
|
||||
content: string
|
||||
}
|
||||
|
||||
interface UseAdvisorCaseArgs {
|
||||
currentStep: string
|
||||
country: string
|
||||
}
|
||||
|
||||
let counter = 0
|
||||
const uid = (p: string) => `${p}-${Date.now()}-${counter++}`
|
||||
|
||||
/**
|
||||
* Drives the Advisor as a series of CASES. Each ask posts {question, context?} and receives a
|
||||
* structured AdvisorResponse (mode: clarify | answer) — no streaming, no answer-text parsing.
|
||||
* selectContext() re-runs the same case scoped to a chosen domain (clarify -> answer).
|
||||
* Drives the Advisor as topic THREADS of CASES. Each ask posts {question, context?, history} and
|
||||
* receives a structured AdvisorResponse (clarify | answer) — no streaming, no answer-text parsing.
|
||||
* A follow-up appends to the active thread (and carries the thread's prior Q&A as history);
|
||||
* newTopic() starts a fresh thread. selectContext() re-runs a case scoped to a chosen domain.
|
||||
*/
|
||||
export function useAdvisorCase({ currentStep, country }: UseAdvisorCaseArgs) {
|
||||
const [cases, setCases] = useState<AdvisorCase[]>([])
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [activeCaseId, setActiveCaseId] = useState<string | null>(null)
|
||||
const [activeThreadId, setActiveThreadId] = useState<string | null>(null)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
|
||||
const patch = useCallback((id: string, p: Partial<AdvisorCase>) => {
|
||||
setCases((prev) => prev.map((c) => (c.id === id ? { ...c, ...p } : c)))
|
||||
}, [])
|
||||
|
||||
// Prior answered turns of a thread, up to (but excluding) `beforeId`, for contextual follow-ups.
|
||||
const buildHistory = useCallback(
|
||||
(threadId: string, beforeId?: string): HistoryTurn[] => {
|
||||
const turns: HistoryTurn[] = []
|
||||
for (const c of cases) {
|
||||
if (c.threadId !== threadId) continue
|
||||
if (beforeId && c.id === beforeId) break
|
||||
const a = c.response?.answer ?? c.response?.general_answer
|
||||
if (!a) continue
|
||||
turns.push({ role: 'user', content: c.question }, { role: 'assistant', content: a })
|
||||
}
|
||||
return turns
|
||||
},
|
||||
[cases],
|
||||
)
|
||||
|
||||
const run = useCallback(
|
||||
async (id: string, question: string, context: string | null) => {
|
||||
async (id: string, question: string, context: string | null, history: HistoryTurn[]) => {
|
||||
setBusy(true)
|
||||
abortRef.current = new AbortController()
|
||||
try {
|
||||
const res = await fetch('/api/sdk/compliance-advisor/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ question, context, currentStep, country }),
|
||||
body: JSON.stringify({ question, context, history, currentStep, country }),
|
||||
signal: abortRef.current.signal,
|
||||
})
|
||||
if (!res.ok) {
|
||||
@@ -65,33 +100,89 @@ export function useAdvisorCase({ currentStep, country }: UseAdvisorCaseArgs) {
|
||||
)
|
||||
|
||||
const ask = useCallback(
|
||||
(question: string) => {
|
||||
(question: string, opts?: { newThread?: boolean }) => {
|
||||
const q = question.trim()
|
||||
if (!q || busy) return
|
||||
const id = `case-${Date.now()}`
|
||||
const startNew = opts?.newThread || !activeThreadId || cases.length === 0
|
||||
const threadId = startNew ? uid('thread') : activeThreadId!
|
||||
const id = uid('case')
|
||||
const history = startNew ? [] : buildHistory(threadId)
|
||||
setCases((prev) => [
|
||||
...prev,
|
||||
{ id, question: q, response: null, selectedContext: null, status: 'loading' },
|
||||
{ id, threadId, question: q, response: null, selectedContext: null, status: 'loading' },
|
||||
])
|
||||
void run(id, q, null)
|
||||
setActiveThreadId(threadId)
|
||||
setActiveCaseId(id)
|
||||
void run(id, q, null, history)
|
||||
},
|
||||
[busy, run],
|
||||
[busy, activeThreadId, cases.length, buildHistory, run],
|
||||
)
|
||||
|
||||
const newTopic = useCallback((question: string) => ask(question, { newThread: true }), [ask])
|
||||
|
||||
const selectContext = useCallback(
|
||||
(id: string, context: string) => {
|
||||
const c = cases.find((x) => x.id === id)
|
||||
if (!c || busy) return
|
||||
patch(id, { status: 'loading', selectedContext: context })
|
||||
void run(id, c.question, context)
|
||||
void run(id, c.question, context, buildHistory(c.threadId, id))
|
||||
},
|
||||
[cases, busy, run, patch],
|
||||
[cases, busy, run, patch, buildHistory],
|
||||
)
|
||||
|
||||
const remove = useCallback((id: string) => {
|
||||
setCases((prev) => prev.filter((c) => c.id !== id))
|
||||
}, [])
|
||||
|
||||
const selectCase = useCallback((id: string) => {
|
||||
setActiveCaseId(id)
|
||||
setCases((prev) => {
|
||||
const c = prev.find((x) => x.id === id)
|
||||
if (c) setActiveThreadId(c.threadId)
|
||||
return prev
|
||||
})
|
||||
}, [])
|
||||
|
||||
const stop = useCallback(() => {
|
||||
abortRef.current?.abort()
|
||||
setBusy(false)
|
||||
}, [])
|
||||
|
||||
return { cases, busy, ask, selectContext, stop }
|
||||
// Keep the active selection valid after deletions.
|
||||
useEffect(() => {
|
||||
if (activeCaseId && !cases.some((c) => c.id === activeCaseId)) {
|
||||
const last = cases[cases.length - 1] ?? null
|
||||
setActiveCaseId(last?.id ?? null)
|
||||
setActiveThreadId(last?.threadId ?? null)
|
||||
}
|
||||
}, [cases, activeCaseId])
|
||||
|
||||
const threads = useMemo<AdvisorThread[]>(() => {
|
||||
const order: string[] = []
|
||||
const byId = new Map<string, AdvisorThread>()
|
||||
for (const c of cases) {
|
||||
let t = byId.get(c.threadId)
|
||||
if (!t) {
|
||||
t = { id: c.threadId, title: c.question, cases: [] }
|
||||
byId.set(c.threadId, t)
|
||||
order.push(c.threadId)
|
||||
}
|
||||
t.cases.push(c)
|
||||
}
|
||||
return order.map((id) => byId.get(id)!)
|
||||
}, [cases])
|
||||
|
||||
return {
|
||||
cases,
|
||||
threads,
|
||||
busy,
|
||||
activeCaseId,
|
||||
activeThreadId,
|
||||
ask,
|
||||
newTopic,
|
||||
selectContext,
|
||||
selectCase,
|
||||
remove,
|
||||
stop,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useState } from 'react'
|
||||
|
||||
/** Writes text to the clipboard and flags the copied key briefly (for a check-mark affordance). */
|
||||
export function useClipboard(resetMs = 1500) {
|
||||
const [copiedKey, setCopiedKey] = useState<string | null>(null)
|
||||
|
||||
const copy = useCallback(
|
||||
(key: string, text: string) => {
|
||||
void navigator.clipboard?.writeText(text)
|
||||
setCopiedKey(key)
|
||||
window.setTimeout(() => setCopiedKey((k) => (k === key ? null : k)), resetMs)
|
||||
},
|
||||
[resetMs],
|
||||
)
|
||||
|
||||
return { copiedKey, copy }
|
||||
}
|
||||
Reference in New Issue
Block a user