/**
* 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 plan 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, useRef, useState } from "react";
import { limits } from "@chopin/dialect";
import { Provenance, SidecarCard, when } from "./card";
import { Count } from "./count";
import { cursor } from "./cursor";
import type { KeyboardEvent } from "react";
import type { Comment } from "@chopin/protocol";
import type { ThreadView } from "./threads";
function Who({ handle }: { handle: string }) {
return (
@{handle}
);
}
function Note({ note }: { note: Comment.Note }) {
return (
{when(note.ts)}
{note.text}
);
}
const QUOTED = "m-0 w-full text-left text-sm text-text-secondary italic";
/**
* The prose the thread marks, as a quotation the card can be read without.
*
* A button when there is somewhere to go, a blockquote when there is not —
* which is the rule `QuestionView` already applies to an answer, so both halves
* of the sidecar offer the same thing in the same way. A real element carries
* the affordance and the keyboard handling rather than a quotation pretending
* to be one, and a drifted thread offers no jump it could not honour: the card
* still reads, which is the durable part of a comment.
*
* The quote rather than the card. A card holds a reply box, an Accept and a
* Dismiss; making the whole of it a link would mean deciding, on every click,
* whether the reader meant the link or the control they actually hit.
*
* Two numbers meet here and only one of them is drawn. How many places the
* thread is anchored in belongs to the jump — it says what the button will do,
* so it is said in the label and nowhere else. How many replies the thread has
* collected is the count worth acting on, so that is what takes the pill. The
* pill sits outside the button and carries its own word, so a drifted thread
* with no jump left to offer still counts its replies out loud.
*/
function Quote(
{ drifted, onSelect, places = 0, replies = 0, text }: {
drifted?: boolean;
onSelect?: () => void;
/** How many spans of prose the thread is anchored in. */
places?: number;
/** Answers to the opening comment, which is not itself a reply. */
replies?: number;
text: string;
},
) {
return (
);
}
/**
* 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,
}: {
autoFocus?: boolean;
busy?: boolean;
label: string;
onCancel?: () => void;
onSend: (text: string) => void;
onTyping?: (writing: boolean) => void;
placeholder: string;
}) {
let [text, setText] = useState("");
let ref = useRef(null);
useEffect(() => {
if (autoFocus) ref.current?.focus();
}, [autoFocus]);
// 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) return onCancel();
if (event.key !== "Enter" || event.shiftKey) return;
event.preventDefault();
send();
};
return (
);
}
/** A button that asks again before doing something that cannot be undone. */
function Confirm({
busy,
consequence,
label,
onConfirm,
tone,
}: {
busy?: boolean;
consequence?: string;
label: string;
onConfirm: () => void;
tone: "primary" | "quiet";
}) {
let [asked, setAsked] = useState(false);
useEffect(() => {
if (!asked) return;
let timer = setTimeout(() => setAsked(false), 4_000);
return () => clearTimeout(timer);
}, [asked]);
return (
<>
{consequence && (
// Keep the live region mounted so later text changes are announced.
{asked && consequence}
)}
>
);
}
export type ThreadCardProps = {
view: ThreadView;
quote: string;
writing?: string[];
focused?: boolean;
busy?: 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;
/** Take the reader to the prose this points at. */
onReveal: () => void;
/** Dismiss a document dialog without changing the durable thread. */
onClose?: () => void;
/** The overlay is document chrome rather than an item in the decisions rail. */
inDocument?: boolean;
};
export function ThreadCard({
applied,
busy,
focused,
onAccept,
onBlur,
onClose,
onDismiss,
onFocus,
onReply,
onRetry,
onReveal,
onTyping,
quote,
inDocument,
view,
writing,
}: ThreadCardProps) {
let { thread } = view;
let open = thread.status === "open";
return (
Not yet applied
>
)}
label="Comment"
onBlur={onBlur}
onFocus={onFocus}
onMouseEnter={onFocus}
onMouseLeave={onBlur}
settled={!open}
status={!open && }
>
{onClose && (