/**
* 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, ChevronIcon, CloseIcon, MessageIcon } from "@chopin/icons";
import { limits } from "@chopin/dialect";
import { Provenance, SidecarCard, when } from "./card";
import { displayName } from "./display-name";
import { Face } from "./face";
import { PRIMARY_COARSE_POINTER_QUERY } from "./pointer";
import type { KeyboardEvent, ReactNode } from "react";
import type { Comment } from "@chopin/protocol";
import type { ThreadView } from "./threads";
/** Chat's author row: face, display name, quiet timestamp. The handle stays in the name. */
export function Author(
{ handle, textSize = "sm", ts }: {
handle: string;
textSize?: "xs" | "sm";
ts?: number;
},
) {
return (
{displayName(handle)}
(@{handle})
{ts !== undefined && (
{when(ts)}
)}
);
}
function Note({
action,
note,
opening,
}: {
action?: ReactNode;
note: Comment.Note;
opening?: boolean;
}) {
return (
{action}
{note.text}
);
}
function CloseButton({ onClose }: { onClose: () => void }) {
return (
);
}
function DraftHeader({ onClose }: { onClose: () => void }) {
return (
);
}
/** 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.
)}
);
}
/**
* What a key does in a comment composer.
*
* Enter sends and Shift-Enter is a newline, the convention the chat composer
* follows. A soft keyboard has no Shift-Enter, so on a coarse pointer Enter is a
* newline and only the send button sends. Enter that confirms an IME candidate
* never sends.
*/
export function composerKey(
event: { key: string; shiftKey: boolean; isComposing: boolean; keyCode?: number },
coarse: boolean,
): "send" | "cancel" | undefined {
if (event.key === "Escape") return "cancel";
if (event.key !== "Enter" || event.shiftKey || coarse) return undefined;
if (event.isComposing || event.keyCode === 229) return undefined;
return "send";
}
/** A textarea with its send button inside, shared by new comments and replies. */
function Composer({
autoFocus,
busy,
label,
onCancel,
onSend,
onTyping,
placeholder,
sendLabel,
}: {
autoFocus?: boolean;
busy?: boolean;
label: string;
onCancel?: () => void;
onSend: (text: string) => void;
onTyping?: (writing: boolean) => void;
placeholder: string;
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) => {
let action = composerKey(
{
key: event.key,
shiftKey: event.shiftKey,
isComposing: event.nativeEvent.isComposing,
keyCode: event.keyCode,
},
matchMedia(PRIMARY_COARSE_POINTER_QUERY).matches,
);
if (action === "cancel" && onCancel) {
event.preventDefault();
onCancel();
} else if (action === "send") {
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;
/** Return to the list of a block's threads this one was opened from. */
onBack?: () => void;
backLabel?: string;
};
export function ThreadCard({
applied,
busy,
canEdit = true,
focused,
onAccept,
onBlur,
onClose,
onDismiss,
onFocus,
onReply,
onRetry,
onTyping,
quote,
inDocument,
onBack,
backLabel,
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 && }
>
{onBack && (
)}
)}
>
)}
);
}
export type ThreadListProps = {
views: ThreadView[];
/** A compact sheet focuses its own grabber instead. */
autoFocus?: boolean;
/** The thread just left with Back; its item takes focus, even in a sheet. */
returnTo?: string;
onSelect: (id: string) => void;
onClose?: () => void;
showClose?: boolean;
};
/** Several threads on one block, as one stop that opens into each of them. */
export function ThreadList(
{ autoFocus = true, onClose, onSelect, returnTo, showClose = true, views }: ThreadListProps,
) {
let list = useRef(null);
useEffect(() => {
let items = list.current;
let item = returnTo
? items?.querySelector(`[data-plan-comment-group-item="${returnTo}"]`)
: undefined;
if (item) item.focus();
else if (autoFocus) {
items?.querySelector("[data-plan-comment-group-item]")?.focus();
}
// Focus once, when the list opens; later changes to its threads leave focus where it is.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
let move = (event: KeyboardEvent) => {
let items = Array.from(
list.current?.querySelectorAll("[data-plan-comment-group-item]") ?? [],
);
let index = items.indexOf(document.activeElement as HTMLElement);
let next = event.key === "ArrowDown"
? (index + 1) % items.length
: event.key === "ArrowUp"
? (index - 1 + items.length) % items.length
: event.key === "Home"
? 0
: event.key === "End"
? items.length - 1
: undefined;
if (next === undefined || items.length === 0) return;
event.preventDefault();
items[next]?.focus();
};
return (
{views.length} comments
{showClose && onClose && }
{views.map(view => {
let opening = view.thread.notes[0];
let replies = Math.max(0, view.thread.notes.length - 1);
return (
);
})}
);
}
export type DraftCardProps = {
busy?: boolean;
onSend: (text: string) => void;
onCancel: () => void;
showClose?: boolean;
};
/** A compact sheet shows its own close beside the grabber, so its draft has no header row. */
export function DraftCard({ busy, onCancel, onSend, showClose = true }: DraftCardProps) {
return (
{showClose && }
);
}