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:
autocommit 2026-04-19 02:18:40 -07:00
parent 9ae7ade166
commit c348ca38f6
2 changed files with 376 additions and 0 deletions

View file

@ -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;
}

View file

@ -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 &amp; 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>
);
};