/** * 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 (
    {places > 0 && onSelect ? ( ) :
    {text}
    } {replies > 0 && ( {replies} {replies === 1 ? " reply" : " replies"} )}
    {drifted && (

    The text this refers to has changed.

    )}
    ); } /** * 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 (