feat(conversation-assistant): ✨ Introduce DraftResponsePanel.tsx with scoped CSS styling for draft response management UI
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
9ae7ade166
commit
c348ca38f6
2 changed files with 376 additions and 0 deletions
|
|
@ -0,0 +1,179 @@
|
|||
.panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background-color: var(--bg-secondary);
|
||||
border: 1px dashed var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
max-width: 520px;
|
||||
}
|
||||
|
||||
.topRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.themeBadge {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 3px 8px;
|
||||
border-radius: 12px;
|
||||
background-color: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.draftText {
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.acceptButton,
|
||||
.editButton,
|
||||
.skipButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.acceptButton {
|
||||
background-color: rgba(34, 197, 94, 0.15);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.acceptButton:hover:not(:disabled) {
|
||||
background-color: rgba(34, 197, 94, 0.25);
|
||||
}
|
||||
|
||||
.editButton {
|
||||
background-color: rgba(99, 102, 241, 0.12);
|
||||
color: #818cf8;
|
||||
}
|
||||
|
||||
.editButton:hover:not(:disabled) {
|
||||
background-color: rgba(99, 102, 241, 0.22);
|
||||
}
|
||||
|
||||
.skipButton {
|
||||
background-color: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.skipButton:hover:not(:disabled) {
|
||||
background-color: var(--border);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.acceptButton:disabled,
|
||||
.editButton:disabled,
|
||||
.skipButton:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.editArea {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.textarea {
|
||||
width: 100%;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background-color: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
resize: vertical;
|
||||
font-family: inherit;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.textarea:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.editActions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.saveButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: none;
|
||||
background-color: rgba(34, 197, 94, 0.15);
|
||||
color: #22c55e;
|
||||
}
|
||||
|
||||
.saveButton:hover:not(:disabled) {
|
||||
background-color: rgba(34, 197, 94, 0.25);
|
||||
}
|
||||
|
||||
.saveButton:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.cancelButton {
|
||||
padding: 6px 12px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background-color: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.cancelButton:hover:not(:disabled) {
|
||||
background-color: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.cancelButton:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
|
@ -0,0 +1,197 @@
|
|||
import { useState } from 'react';
|
||||
|
||||
import { CheckIcon, EditIcon, SkipForwardIcon } from '@lilith/ui-icons';
|
||||
|
||||
import {
|
||||
useAcceptResponse,
|
||||
useRejectResponse,
|
||||
useEditResponse,
|
||||
type GeneratedResponse,
|
||||
} from '@/api';
|
||||
import { ConfidenceIndicator } from '@/components/ConfidenceIndicator';
|
||||
|
||||
import styles from './DraftResponsePanel.module.css';
|
||||
|
||||
interface DraftResponsePanelProps {
|
||||
drafts: GeneratedResponse[];
|
||||
onDraftActioned: () => void;
|
||||
}
|
||||
|
||||
type Theme = GeneratedResponse['theme'];
|
||||
|
||||
const THEME_LABELS: Record<NonNullable<Theme>, string> = {
|
||||
flirty: '💬 Flirty',
|
||||
flirty_closing: '📅 Closing',
|
||||
deflection: '🛡 Deflect',
|
||||
};
|
||||
|
||||
function getThemeLabel(theme: Theme): string {
|
||||
if (theme && theme in THEME_LABELS) {
|
||||
return THEME_LABELS[theme as NonNullable<Theme>];
|
||||
}
|
||||
return '✨ Draft';
|
||||
}
|
||||
|
||||
interface DraftCardProps {
|
||||
draft: GeneratedResponse;
|
||||
onDismiss: (id: string) => void;
|
||||
onActioned: () => void;
|
||||
}
|
||||
|
||||
const DraftCard = ({ draft, onDismiss, onActioned }: DraftCardProps) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editText, setEditText] = useState(draft.editedResponse ?? draft.response);
|
||||
|
||||
const acceptMutation = useAcceptResponse();
|
||||
const rejectMutation = useRejectResponse();
|
||||
const editMutation = useEditResponse();
|
||||
|
||||
const isPending =
|
||||
acceptMutation.isPending || rejectMutation.isPending || editMutation.isPending;
|
||||
|
||||
const handleAccept = () => {
|
||||
acceptMutation.mutate(draft.id, {
|
||||
onSuccess: () => {
|
||||
onDismiss(draft.id);
|
||||
onActioned();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
rejectMutation.mutate(
|
||||
{ id: draft.id, reason: 'skipped' },
|
||||
{
|
||||
onSuccess: () => {
|
||||
onDismiss(draft.id);
|
||||
onActioned();
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const handleSaveEdit = () => {
|
||||
editMutation.mutate(
|
||||
{ id: draft.id, response: editText },
|
||||
{
|
||||
onSuccess: () => {
|
||||
acceptMutation.mutate(draft.id, {
|
||||
onSuccess: () => {
|
||||
onDismiss(draft.id);
|
||||
onActioned();
|
||||
},
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<div className={styles.topRow}>
|
||||
<span className={styles.themeBadge}>{getThemeLabel(draft.theme)}</span>
|
||||
<ConfidenceIndicator confidence={draft.confidence} showLabel />
|
||||
</div>
|
||||
|
||||
{isEditing ? (
|
||||
<div className={styles.editArea}>
|
||||
<textarea
|
||||
className={styles.textarea}
|
||||
value={editText}
|
||||
onChange={(e) => setEditText(e.target.value)}
|
||||
disabled={isPending}
|
||||
rows={4}
|
||||
aria-label="Edit draft response"
|
||||
/>
|
||||
<div className={styles.editActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.saveButton}
|
||||
onClick={handleSaveEdit}
|
||||
disabled={isPending || editText.trim() === ''}
|
||||
>
|
||||
<CheckIcon size={14} aria-hidden="true" />
|
||||
Save & Accept
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.cancelButton}
|
||||
onClick={() => {
|
||||
setEditText(draft.editedResponse ?? draft.response);
|
||||
setIsEditing(false);
|
||||
}}
|
||||
disabled={isPending}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className={styles.draftText}>{draft.editedResponse ?? draft.response}</p>
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.acceptButton}
|
||||
onClick={handleAccept}
|
||||
disabled={isPending}
|
||||
aria-label="Accept this draft"
|
||||
>
|
||||
<CheckIcon size={14} aria-hidden="true" />
|
||||
Accept
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.editButton}
|
||||
onClick={() => setIsEditing(true)}
|
||||
disabled={isPending}
|
||||
aria-label="Edit this draft"
|
||||
>
|
||||
<EditIcon size={14} aria-hidden="true" />
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.skipButton}
|
||||
onClick={handleSkip}
|
||||
disabled={isPending}
|
||||
aria-label="Skip this draft"
|
||||
>
|
||||
<SkipForwardIcon size={14} aria-hidden="true" />
|
||||
Skip
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const DraftResponsePanel = ({ drafts, onDraftActioned }: DraftResponsePanelProps) => {
|
||||
const [dismissedIds, setDismissedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const handleDismiss = (id: string) => {
|
||||
setDismissedIds((prev) => new Set(prev).add(id));
|
||||
};
|
||||
|
||||
const visibleDrafts = drafts.filter(
|
||||
(d) => d.status === 'completed' && !dismissedIds.has(d.id)
|
||||
);
|
||||
|
||||
if (visibleDrafts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.panel} role="region" aria-label="AI draft responses">
|
||||
{visibleDrafts.map((draft) => (
|
||||
<DraftCard
|
||||
key={draft.id}
|
||||
draft={draft}
|
||||
onDismiss={handleDismiss}
|
||||
onActioned={onDraftActioned}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue