/**
* A comment thread, as a card in the sidecar.
*
* Four states, and the difference between them is who can still act. A draft
* has a passage but no thread yet. An open thread takes replies from anyone.
* An accepted one is frozen — that is what accepting means — and reads as a
* decision. A dismissed one is not rendered at all.
*
* Accept and dismiss both confirm on a second click, because neither can be
* undone: accepting freezes the thread, puts a decision in the document and starts
* a turn. That is the same two-click shape `QuestionView` uses for cancelling,
* so it is an interaction people have already met here.
*/
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { ArrowUpIcon, CheckIcon, CloseIcon, MessageIcon } from "@chopin/icons";
import { limits } from "@chopin/dialect";
import { Provenance, SidecarCard, when } from "./card";
import type { KeyboardEvent, ReactNode } from "react";
import type { Comment } from "@chopin/protocol";
import type { ThreadView } from "./threads";
function Who({ handle }: { handle: string }) {
return (
@{handle}
);
}
function Note({
action,
note,
opening,
}: {
action?: ReactNode;
note: Comment.Note;
opening?: boolean;
}) {
return (
{when(note.ts)}
{action}
{note.text}
);
}
function CloseButton({ onClose }: { onClose: () => void }) {
return (
);
}
function DraftHeader({ onClose, showClose }: { onClose: () => void; showClose: boolean }) {
return (
{showClose && }
);
}
/** Context is repeated only when its source passage can no longer be reached. */
function Quote({ drifted, text }: { drifted?: boolean; text: string }) {
return (
{text}
{drifted && (
This passage has changed since the comment was added.
)}
);
}
/**
* A textarea that submits on Enter.
*
* Shift-Enter is a newline, which is the convention every chat surface uses and
* the one the composer next door already follows.
*/
function Composer({
autoFocus,
busy,
label,
onCancel,
onSend,
onTyping,
placeholder,
insetSend,
sendLabel,
}: {
autoFocus?: boolean;
busy?: boolean;
label: string;
onCancel?: () => void;
onSend: (text: string) => void;
onTyping?: (writing: boolean) => void;
placeholder: string;
insetSend?: boolean;
sendLabel?: string;
}) {
let [text, setText] = useState("");
let ref = useRef(null);
useEffect(() => {
if (autoFocus) ref.current?.focus();
}, [autoFocus]);
useLayoutEffect(() => {
let field = ref.current;
if (!field) return;
field.style.height = "0px";
let height = Math.min(field.scrollHeight, 160);
field.style.height = `${height}px`;
field.style.overflowY = field.scrollHeight > height ? "auto" : "hidden";
}, [text]);
// Whoever is typing stops being told about the moment this goes away, so
// an unmount does not leave a caret blinking in somebody else's sidecar.
useEffect(() => () => onTyping?.(false), [onTyping]);
let send = () => {
let value = text.trim();
if (!value || busy) return;
setText("");
onTyping?.(false);
onSend(value);
};
let key = (event: KeyboardEvent) => {
if (event.key === "Escape" && onCancel) {
event.preventDefault();
return onCancel();
}
if (event.key !== "Enter" || event.shiftKey) return;
event.preventDefault();
send();
};
return (
);
}
export type ThreadCardProps = {
view: ThreadView;
quote: string;
writing?: string[];
focused?: boolean;
busy?: boolean;
/** Whether durable thread actions are available to this viewer. */
canEdit?: boolean;
/** True once the agent has said what an accepted thread produced. */
applied?: boolean;
onReply: (text: string) => void;
onAccept: () => void;
onDismiss: () => void;
onRetry: () => void;
onTyping: (writing: boolean) => void;
onFocus: () => void;
onBlur: () => void;
/** Dismiss a document dialog without changing the durable thread. */
onClose?: () => void;
/** Desktop owns visible close chrome; a compact sheet owns dismissal itself. */
showClose?: boolean;
/** The overlay is document chrome rather than an item in the decisions rail. */
inDocument?: boolean;
};
export function ThreadCard({
applied,
busy,
canEdit = true,
focused,
onAccept,
onBlur,
onClose,
onDismiss,
onFocus,
onReply,
onRetry,
onTyping,
quote,
inDocument,
showClose = true,
view,
writing,
}: ThreadCardProps) {
let { thread } = view;
let open = thread.status === "open";
let [confirming, setConfirming] = useState<"accept" | "dismiss">();
let confirmation = confirming === "accept"
? {
action: "Apply feedback",
message: "Planner will use this feedback to update the document.",
onConfirm: onAccept,
}
: confirming === "dismiss"
? {
action: "Dismiss",
message: "This closes the thread without changing the document.",
onConfirm: onDismiss,
}
: undefined;
return (
Not yet applied
{canEdit && (
)}
>
)}
label="Comment"
onBlur={onBlur}
onFocus={onFocus}
onMouseEnter={onFocus}
onMouseLeave={onBlur}
settled={!open}
status={!open && }
>