Skip to content

Commit 56b4ef4

Browse files
jkomorosclaudehappy-otterbfollington
authored
Add ct-copy-button component (commontoolsinc#2081)
* Fix: Use derive instead of lift for reactive mentionable tracking Issue: backlinks-index used lift() which computes once at initialization. When allCharms was empty initially, mentionable stayed empty even after charms were added. Fix: Use derive(allCharms, ...) which reactively tracks when allCharms changes and recomputes mentionable automatically. This fixes the bug where [[ dropdown is empty until page refresh. Fixes timing issue reported by Berni where fresh spaces need refresh for mentions to work. * Add ct-copy-button component Implements a reusable copy-to-clipboard button component that: - Composes ct-button internally for consistent styling - Accepts text via the `text` attribute (supports Cell bindings) - Provides automatic visual feedback (icon swap for 2 seconds) - Fires ct-copy-success and ct-copy-error events - Follows security model: patterns provide text, component handles DOM API Component supports: - All ct-button variants (primary, secondary, ghost, etc.) - All ct-button sizes (sm, md, lg, icon, default) - Icon-only mode for compact UIs - Configurable feedback duration - Full TypeScript type definitions Usage: ```tsx <ct-copy-button text={ingredientListText} variant="secondary" size="sm" icon-only /> ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <[email protected]> Co-Authored-By: Happy <[email protected]> * Fix ct-copy-button to properly handle Cell<string> bindings The component now checks if text is a Cell and unwraps it using .get() to avoid copying '[object Object]' when Cell bindings are passed. - Import isCell and Cell type from @commontools/runner - Add _getTextValue() helper to unwrap Cell values - Update type declaration to accept string | Cell<string> - Use _getTextValue() in _handleClick to get actual text value Fixes cubic-dev-ai review comment on PR commontoolsinc#2081 Generated with [Claude Code](https://claude.ai/code) via [Happy](https://happy.engineering) Co-Authored-By: Claude <[email protected]> Co-Authored-By: Happy <[email protected]> * Action PR feedback * Use `ct-copy-button` in `ct-chat-message` * Lint + format * Fix copy-button aspect ratio * Remove reference to cell * Format --------- Co-authored-by: Claude <[email protected]> Co-authored-by: Happy <[email protected]> Co-authored-by: Ben Follington <[email protected]>
1 parent d0f6527 commit 56b4ef4

File tree

7 files changed

+242
-104
lines changed

7 files changed

+242
-104
lines changed

packages/html/src/jsx.d.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2875,6 +2875,7 @@ interface CTCodeEditorLegacyElement extends CTHTMLElement {}
28752875
interface CTScreenElement extends CTHTMLElement {}
28762876
interface CTAutoLayoutElement extends CTHTMLElement {}
28772877
interface CTButtonElement extends CTHTMLElement {}
2878+
interface CTCopyButtonElement extends CTHTMLElement {}
28782879
interface CTIFrameElement extends CTHTMLElement {}
28792880
interface CTHStackElement extends CTHTMLElement {}
28802881
interface CTFabElement extends CTHTMLElement {}
@@ -3075,6 +3076,22 @@ interface CTButtonAttributes<T> extends CTHTMLAttributes<T> {
30753076
"type"?: "button" | "submit" | "reset";
30763077
}
30773078

3079+
interface CTCopyButtonAttributes<T> extends CTHTMLAttributes<T> {
3080+
"text": string;
3081+
"variant"?:
3082+
| "primary"
3083+
| "secondary"
3084+
| "destructive"
3085+
| "outline"
3086+
| "ghost"
3087+
| "link"
3088+
| "pill";
3089+
"size"?: "default" | "sm" | "lg" | "icon" | "md";
3090+
"disabled"?: boolean;
3091+
"feedback-duration"?: number;
3092+
"icon-only"?: boolean;
3093+
}
3094+
30783095
interface CTIframeAttributes<T> extends CTHTMLAttributes<T> {
30793096
"src": string;
30803097
"$context": CellLike<any>;
@@ -3759,6 +3776,10 @@ declare global {
37593776
CTButtonAttributes<CTButtonElement>,
37603777
CTButtonElement
37613778
>;
3779+
"ct-copy-button": CTDOM.DetailedHTMLProps<
3780+
CTCopyButtonAttributes<CTCopyButtonElement>,
3781+
CTCopyButtonElement
3782+
>;
37623783
"common-iframe": CTDOM.DetailedHTMLProps<
37633784
CTIframeAttributes<CTIFrameElement>,
37643785
CTIFrameElement

packages/patterns/backlinks-index.tsx

Lines changed: 5 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/// <cts-enable />
2-
import { Cell, lift, NAME, OpaqueRef, recipe, UI } from "commontools";
2+
import { Cell, derive, lift, NAME, OpaqueRef, recipe, UI } from "commontools";
33

44
export type MentionableCharm = {
55
[NAME]?: string;
@@ -63,13 +63,9 @@ const BacklinksIndex = recipe<Input, Output>(
6363
allCharms: allCharms as unknown as OpaqueRef<WriteableBacklinks[]>,
6464
});
6565

66-
// Compute mentionable list from allCharms via lift, then mirror that into
67-
// a real Cell for downstream consumers that expect a Cell.
68-
const computeMentionable = lift<
69-
{ allCharms: any[] },
70-
MentionableCharm[]
71-
>(({ allCharms }) => {
72-
const cs = allCharms ?? [];
66+
// Compute mentionable list from allCharms reactively
67+
const mentionable = derive(allCharms, (charmList) => {
68+
const cs = charmList ?? [];
7369
const out: MentionableCharm[] = [];
7470
for (const c of cs) {
7571
out.push(c);
@@ -90,7 +86,7 @@ const BacklinksIndex = recipe<Input, Output>(
9086
return {
9187
[NAME]: "BacklinksIndex",
9288
[UI]: undefined,
93-
mentionable: computeMentionable({ allCharms }),
89+
mentionable,
9490
};
9591
},
9692
);

packages/ui/src/v2/components/ct-chat-message/ct-chat-message.ts

Lines changed: 9 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { marked } from "marked";
66
import { BaseElement } from "../../core/base-element.ts";
77
import "../ct-tool-call/ct-tool-call.ts";
88
import "../ct-button/ct-button.ts";
9+
import "../ct-copy-button/ct-copy-button.ts";
910
import type {
1011
BuiltInLLMContent,
1112
BuiltInLLMTextPart,
@@ -373,11 +374,6 @@ export class CTChatMessage extends BaseElement {
373374
@property({ attribute: false })
374375
declare theme?: CTTheme;
375376

376-
@property({ type: Boolean })
377-
private _copied = false;
378-
379-
private _codeBlockCopiedStates = new Map<string, boolean>();
380-
381377
constructor() {
382378
super();
383379
this.role = "user";
@@ -407,22 +403,18 @@ export class CTChatMessage extends BaseElement {
407403
return html.replace(
408404
/<pre><code([^>]*)>([\s\S]*?)<\/code><\/pre>/g,
409405
(_match, codeAttrs, codeContent) => {
410-
const blockId = `code-${Math.random().toString(36).substr(2, 9)}`;
411406
// Decode HTML entities for the copy content
412407
const decodedContent = this._decodeHtmlEntities(codeContent);
413408

414409
return `<div class="code-block-container">
415410
<pre><code${codeAttrs}>${codeContent}</code></pre>
416-
<ct-button
411+
<ct-copy-button
417412
class="code-copy-button"
413+
text="${this._escapeForAttribute(decodedContent)}"
418414
variant="ghost"
419415
size="sm"
420-
data-block-id="${blockId}"
421-
data-copy-content="${this._escapeForAttribute(decodedContent)}"
422-
title="Copy code"
423-
>
424-
📋
425-
</ct-button>
416+
icon-only
417+
></ct-copy-button>
426418
</div>`;
427419
},
428420
);
@@ -443,61 +435,6 @@ export class CTChatMessage extends BaseElement {
443435
.replace(/>/g, "&gt;");
444436
}
445437

446-
private async _copyMessage() {
447-
const textContent = this._extractTextContent();
448-
if (!textContent) return;
449-
450-
try {
451-
await navigator.clipboard.writeText(textContent);
452-
this._copied = true;
453-
454-
// Reset copied state after 2 seconds
455-
setTimeout(() => {
456-
this._copied = false;
457-
this.requestUpdate();
458-
}, 2000);
459-
460-
this.requestUpdate();
461-
} catch (err) {
462-
console.error("Failed to copy message:", err);
463-
}
464-
}
465-
466-
private async _copyCodeBlock(blockId: string, content: string) {
467-
try {
468-
// Decode the content from the attribute
469-
const decodedContent = content
470-
.replace(/&amp;/g, "&")
471-
.replace(/&quot;/g, '"')
472-
.replace(/&#39;/g, "'")
473-
.replace(/&lt;/g, "<")
474-
.replace(/&gt;/g, ">");
475-
476-
await navigator.clipboard.writeText(decodedContent);
477-
this._codeBlockCopiedStates.set(blockId, true);
478-
479-
// Update the button to show copied state
480-
const button = this.shadowRoot?.querySelector(
481-
`[data-block-id="${blockId}"]`,
482-
) as HTMLElement;
483-
if (button) {
484-
button.textContent = "✓";
485-
button.title = "Copied!";
486-
}
487-
488-
// Reset copied state after 2 seconds
489-
setTimeout(() => {
490-
this._codeBlockCopiedStates.set(blockId, false);
491-
if (button) {
492-
button.textContent = "📋";
493-
button.title = "Copy code";
494-
}
495-
}, 2000);
496-
} catch (err) {
497-
console.error("Failed to copy code block:", err);
498-
}
499-
}
500-
501438
private _renderToolAttachments() {
502439
// Extract tool calls and results from content array
503440
const contentArray = Array.isArray(this.content) ? this.content : [];
@@ -579,14 +516,12 @@ export class CTChatMessage extends BaseElement {
579516
<div class="message-actions">
580517
${this.role === "assistant"
581518
? html`
582-
<ct-button
519+
<ct-copy-button
520+
text="${textContent}"
583521
variant="ghost"
584522
size="sm"
585-
@click="${this._copyMessage}"
586-
title="${this._copied ? "Copied!" : "Copy message"}"
587-
>
588-
${this._copied ? "✓" : "📋"}
589-
</ct-button>
523+
icon-only
524+
></ct-copy-button>
590525
`
591526
: null}
592527
</div>
@@ -610,34 +545,13 @@ export class CTChatMessage extends BaseElement {
610545
if (changedProperties.has("theme") && this.theme) {
611546
this._updateThemeProperties();
612547
}
613-
614-
// Add event listeners to code copy buttons after render
615-
if (changedProperties.has("content")) {
616-
this._setupCodeCopyButtons();
617-
}
618548
}
619549

620550
private _updateThemeProperties() {
621551
if (!this.theme) return;
622552
applyThemeToElement(this, this.theme);
623553
}
624554

625-
private _setupCodeCopyButtons() {
626-
const copyButtons = this.shadowRoot?.querySelectorAll(".code-copy-button");
627-
copyButtons?.forEach((button) => {
628-
button.addEventListener("click", (e) => {
629-
e.preventDefault();
630-
e.stopPropagation();
631-
const target = e.target as HTMLElement;
632-
const blockId = target.getAttribute("data-block-id");
633-
const content = target.getAttribute("data-copy-content");
634-
if (blockId && content) {
635-
this._copyCodeBlock(blockId, content);
636-
}
637-
});
638-
});
639-
}
640-
641555
override render() {
642556
const messageClass = `message message-${this.role}${
643557
this.streaming ? " streaming" : ""
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* Tests for CTCopyButton component
3+
*/
4+
import { expect } from "@std/expect";
5+
import { describe, it } from "@std/testing/bdd";
6+
import { CTCopyButton } from "./ct-copy-button.ts";
7+
8+
describe("CTCopyButton", () => {
9+
it("should be defined", () => {
10+
expect(CTCopyButton).toBeDefined();
11+
});
12+
13+
it("should have customElement definition", () => {
14+
expect(customElements.get("ct-copy-button")).toBe(CTCopyButton);
15+
});
16+
17+
it("should create element instance", () => {
18+
const element = new CTCopyButton();
19+
expect(element).toBeInstanceOf(CTCopyButton);
20+
});
21+
22+
it("should have default properties", () => {
23+
const element = new CTCopyButton();
24+
expect(element.text).toBe("");
25+
expect(element.variant).toBe("secondary");
26+
expect(element.size).toBe("default");
27+
expect(element.disabled).toBe(false);
28+
expect(element.feedbackDuration).toBe(2000);
29+
expect(element.iconOnly).toBe(false);
30+
});
31+
});

0 commit comments

Comments
 (0)