/**
* 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 { cursor } from "./cursor";
import type { KeyboardEvent } from "react";
import type { Comment } from "@chopin/protocol";
import type { ThreadView } from "./threads";
function when(ts: number): string {
return new Date(ts * 1_000).toLocaleString(undefined, {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
function Who({ handle }: { handle: string }) {
return (
@{handle}
);
}
function Note({ note }: { note: Comment.Note }) {
return (
{when(note.ts)}
{note.text}
);
}
/** The prose the thread marks, as a quotation the card can be read without. */
function Quote({ drifted, text }: { drifted?: boolean; text: string }) {
return (
{text}
{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 (
);
}
/** A button that asks again before doing something that cannot be undone. */
function Confirm({
busy,
label,
onConfirm,
tone,
}: {
busy?: boolean;
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]);
let style = tone === "primary"
? "bg-primary text-primary-foreground hover:bg-primary-hover"
: "text-muted-foreground hover:text-foreground";
return (
);
}
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;
};
export function ThreadCard({
applied,
busy,
focused,
onAccept,
onBlur,
onDismiss,
onFocus,
onReply,
onRetry,
onTyping,
quote,
view,
writing,
}: ThreadCardProps) {
let { thread } = view;
let open = thread.status === "open";
return (