feat: streamline conversation workflow and add Jev reply reviews
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { CalendarPlus, Check, ChevronDown, CornerDownRight, Send } from "lucide-react";
|
||||
import { useId, useState } from "react";
|
||||
import { Button } from "#/components/ui/button";
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
import { NativeSelect, NativeSelectOption } from "#/components/ui/native-select";
|
||||
import { Textarea } from "#/components/ui/textarea";
|
||||
import type { Conversation } from "./mock-conversations";
|
||||
import { reviewReply } from "./server-functions";
|
||||
|
||||
export function ConversationDetail({
|
||||
conversation,
|
||||
@@ -26,17 +28,21 @@ export function ConversationDetail({
|
||||
const [review, setReview] = useState<"send" | "schedule" | null>(null);
|
||||
const [slot, setSlot] = useState("");
|
||||
const [scheduled, setScheduled] = useState("");
|
||||
const evaluation = useQuery({
|
||||
queryKey: ["reply-review", conversation.id, draft],
|
||||
queryFn: ({ signal }) =>
|
||||
reviewReply({ data: { conversationId: conversation.id, draft }, signal }),
|
||||
enabled: review === "send" && !!draft.trim(),
|
||||
retry: false,
|
||||
staleTime: Infinity,
|
||||
gcTime: 0,
|
||||
});
|
||||
return (
|
||||
<article
|
||||
className="conversation-workbench"
|
||||
aria-label={`Conversation with ${conversation.name}`}
|
||||
>
|
||||
<div className="conversation-thread">
|
||||
<header className="conversation-summary">
|
||||
<span className="conversation-eyebrow">At a glance</span>
|
||||
<h2>{conversation.subject}</h2>
|
||||
<p>{conversation.summary}</p>
|
||||
</header>
|
||||
<section className="conversation-messages" aria-label="Original messages">
|
||||
{conversation.messages.map((message) => (
|
||||
<div
|
||||
@@ -59,32 +65,46 @@ export function ConversationDetail({
|
||||
<section aria-label="Compose a reply" className="conversation-compose">
|
||||
<div className="conversation-compose-heading">
|
||||
<label htmlFor={`${instanceId}-reply`}>Reply</label>
|
||||
<span>via {conversation.replyChannel}</span>
|
||||
<span>To {conversation.name.split(" ")[0]}</span>
|
||||
</div>
|
||||
<div className="conversation-draft-options" aria-label="Reply suggestions">
|
||||
{conversation.drafts.map((option) => (
|
||||
<button
|
||||
key={option.label}
|
||||
type="button"
|
||||
aria-pressed={tone === option.label}
|
||||
onClick={() => {
|
||||
setDraft(option.text);
|
||||
setTone(option.label);
|
||||
}}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
<details className="conversation-suggestions">
|
||||
<summary>
|
||||
Reply suggestions <ChevronDown aria-hidden="true" />
|
||||
</summary>
|
||||
<div className="conversation-draft-options" aria-label="Reply suggestions">
|
||||
{conversation.drafts.map((option) => (
|
||||
<button
|
||||
key={option.label}
|
||||
type="button"
|
||||
aria-pressed={tone === option.label}
|
||||
onClick={() => {
|
||||
setDraft(option.text);
|
||||
setTone(option.label);
|
||||
}}
|
||||
>
|
||||
<span>{option.label}</span>
|
||||
<p>{option.text}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
<div className="conversation-editor">
|
||||
<Textarea
|
||||
id={`${instanceId}-reply`}
|
||||
value={draft}
|
||||
onChange={(event) => {
|
||||
setDraft(event.target.value);
|
||||
setTone("");
|
||||
}}
|
||||
/>
|
||||
<div className="conversation-compose-footer">
|
||||
<span>Draft · {conversation.replyChannel}</span>
|
||||
<Button size="sm" disabled={!draft.trim()} onClick={() => setReview("send")}>
|
||||
<Send />
|
||||
Review reply
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Textarea
|
||||
id={`${instanceId}-reply`}
|
||||
value={draft}
|
||||
className="min-h-28 resize-y text-sm leading-7"
|
||||
onChange={(event) => {
|
||||
setDraft(event.target.value);
|
||||
setTone("");
|
||||
}}
|
||||
/>
|
||||
{conversation.scheduleOptions.length > 0 && (
|
||||
<details className="conversation-scheduling">
|
||||
<summary>
|
||||
@@ -134,13 +154,6 @@ export function ConversationDetail({
|
||||
{scheduled} held in this preview
|
||||
</p>
|
||||
)}
|
||||
<div className="conversation-compose-footer">
|
||||
<span>Nothing sends without review</span>
|
||||
<Button disabled={!draft.trim()} onClick={() => setReview("send")}>
|
||||
<Send />
|
||||
Review reply
|
||||
</Button>
|
||||
</div>
|
||||
<div className="conversation-triage">
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -175,6 +188,11 @@ export function ConversationDetail({
|
||||
<h3>{conversation.name}</h3>
|
||||
<p>{conversation.role}</p>
|
||||
</div>
|
||||
<header className="conversation-summary">
|
||||
<span className="conversation-eyebrow">Summary</span>
|
||||
<h2>{conversation.subject}</h2>
|
||||
<p>{conversation.summary}</p>
|
||||
</header>
|
||||
<section className="conversation-next">
|
||||
<h3>
|
||||
<CornerDownRight aria-hidden="true" />
|
||||
@@ -243,11 +261,50 @@ export function ConversationDetail({
|
||||
{review === "schedule" ? slot : draft}
|
||||
</p>
|
||||
</div>
|
||||
{review === "send" && (
|
||||
<section className="conversation-evaluation" aria-label="Jev review" aria-live="polite">
|
||||
<h3>Jev review</h3>
|
||||
{evaluation.isPending ? (
|
||||
<p role="status">Checking this reply against the conversation…</p>
|
||||
) : evaluation.data?.status === "reviewed" ? (
|
||||
<>
|
||||
<ul>
|
||||
{evaluation.data.checks.map((check) => (
|
||||
<li key={check.id}>
|
||||
<span>{check.label}</span>
|
||||
<strong>{Math.round(check.probability * 100)}%</strong>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<p>
|
||||
Estimated likelihood that each criterion is met. Review the wording yourself.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<div>
|
||||
<p>
|
||||
{evaluation.data?.status === "unavailable"
|
||||
? evaluation.data.message
|
||||
: "Review unavailable. Please try again."}
|
||||
</p>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={evaluation.isFetching}
|
||||
onClick={() => void evaluation.refetch()}
|
||||
>
|
||||
{evaluation.isFetching ? "Checking…" : "Retry review"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setReview(null)}>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
disabled={review === "send" && evaluation.isFetching}
|
||||
onClick={() => {
|
||||
if (review === "schedule") setScheduled(slot);
|
||||
else
|
||||
|
||||
Reference in New Issue
Block a user