feat: streamline conversation workflow and add Jev reply reviews

This commit is contained in:
2026-09-28 23:45:07 +09:00
parent 54d7a542f7
commit e51cafa570
14 changed files with 621 additions and 135 deletions
+93 -36
View File
@@ -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