Closes #246. The transcript, composer and approval controls move from structured.ts/structuredRender.ts into components under web/src/components/structured/, against docs/STRUCTURED-VIEW.md: eight row kinds, both projections, the single-active approval nonce, and diff rendering with its own line counts. The pure layers stay shared — structuredDiff.ts computes the diff, structuredRender.ts still sanitizes markdown — and model.ts holds the client-side decisions (activity grouping, notice classification, offered decisions, `/` completion) so they are testable without a DOM. Like TerminalPane, the pane draws the body only; pane chrome reads the connection through onStatusChange. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
102 lines
3.0 KiB
TypeScript
102 lines
3.0 KiB
TypeScript
// docs/STRUCTURED-VIEW.md: the web client's user, agent, thought, tool-detail
|
|
// and notice text blocks copy their complete source text when clicked or
|
|
// keyboard-activated. The visible node is whatever it wraps; this only adds
|
|
// the affordance and the "copied"/"error" state the CSS reads.
|
|
|
|
import { useCallback, useEffect, useRef, useState, type ElementType, type ReactNode } from "react";
|
|
|
|
async function writeClipboard(text: string): Promise<void> {
|
|
try {
|
|
await navigator.clipboard.writeText(text);
|
|
return;
|
|
} catch {
|
|
const textarea = document.createElement("textarea");
|
|
textarea.value = text;
|
|
textarea.style.position = "fixed";
|
|
textarea.style.opacity = "0";
|
|
document.body.appendChild(textarea);
|
|
textarea.select();
|
|
const copied = document.execCommand?.("copy") ?? false;
|
|
textarea.remove();
|
|
if (!copied) throw new Error("clipboard unavailable");
|
|
}
|
|
}
|
|
|
|
export interface CopyableProps {
|
|
/** The complete source text — not whatever the DOM happens to show. */
|
|
text: string;
|
|
/** Names the thing in the "Copy …" label ("message", "diff", "path", …). */
|
|
label: string;
|
|
as?: ElementType;
|
|
className?: string;
|
|
title?: string;
|
|
children?: ReactNode;
|
|
/** Extra attributes the row builders set (`data-row-id`, `lang`, …). */
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
/** Wrapping a node in a copy affordance. Empty `text` renders the node plain —
|
|
* a control that copies nothing is worse than no control. */
|
|
export function Copyable({
|
|
text,
|
|
label,
|
|
as: Tag = "div",
|
|
className,
|
|
children,
|
|
...rest
|
|
}: CopyableProps) {
|
|
const [copyState, setCopyState] = useState<"copied" | "error" | null>(null);
|
|
const timer = useRef<number | null>(null);
|
|
|
|
useEffect(
|
|
() => () => {
|
|
if (timer.current !== null) window.clearTimeout(timer.current);
|
|
},
|
|
[],
|
|
);
|
|
|
|
const copy = useCallback(() => {
|
|
void writeClipboard(text).then(
|
|
() => {
|
|
setCopyState("copied");
|
|
if (timer.current !== null) window.clearTimeout(timer.current);
|
|
timer.current = window.setTimeout(() => setCopyState(null), 1200);
|
|
},
|
|
() => setCopyState("error"),
|
|
);
|
|
}, [text]);
|
|
|
|
if (!text) {
|
|
return (
|
|
<Tag className={className} {...rest}>
|
|
{children}
|
|
</Tag>
|
|
);
|
|
}
|
|
|
|
const ariaLabel = copyState === "copied" ? `${label} copied` : `Copy ${label}`;
|
|
return (
|
|
<Tag
|
|
className={className ? `${className} st-copyable` : "st-copyable"}
|
|
tabIndex={0}
|
|
role="button"
|
|
aria-label={ariaLabel}
|
|
title={`Copy ${label}`}
|
|
data-copy-state={copyState ?? undefined}
|
|
onClick={(event: React.MouseEvent) => {
|
|
// A link inside the block is a link, not a copy target.
|
|
if ((event.target as Element | null)?.closest("a")) return;
|
|
copy();
|
|
}}
|
|
onKeyDown={(event: React.KeyboardEvent) => {
|
|
if (event.key !== "Enter" && event.key !== " ") return;
|
|
event.preventDefault();
|
|
copy();
|
|
}}
|
|
{...rest}
|
|
>
|
|
{children}
|
|
</Tag>
|
|
);
|
|
}
|