@extrachill/chat 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/README.md +1 -1
  3. package/css/chat.css +184 -1
  4. package/dist/Chat.d.ts +22 -1
  5. package/dist/Chat.d.ts.map +1 -1
  6. package/dist/Chat.js +3 -2
  7. package/dist/client-context.d.ts +31 -0
  8. package/dist/client-context.d.ts.map +1 -0
  9. package/dist/client-context.js +100 -0
  10. package/dist/components/ChatMessages.d.ts +18 -1
  11. package/dist/components/ChatMessages.d.ts.map +1 -1
  12. package/dist/components/ChatMessages.js +5 -1
  13. package/dist/components/CopyTranscriptButton.d.ts +12 -0
  14. package/dist/components/CopyTranscriptButton.d.ts.map +1 -0
  15. package/dist/components/CopyTranscriptButton.js +26 -0
  16. package/dist/components/DiffCard.d.ts +40 -0
  17. package/dist/components/DiffCard.d.ts.map +1 -0
  18. package/dist/components/DiffCard.js +162 -0
  19. package/dist/components/ErrorBoundary.d.ts +1 -1
  20. package/dist/components/index.d.ts +1 -0
  21. package/dist/components/index.d.ts.map +1 -1
  22. package/dist/components/index.js +1 -0
  23. package/dist/diff.d.ts +27 -0
  24. package/dist/diff.d.ts.map +1 -0
  25. package/dist/diff.js +109 -0
  26. package/dist/index.d.ts +6 -1
  27. package/dist/index.d.ts.map +1 -1
  28. package/dist/index.js +8 -0
  29. package/dist/transcript.d.ts +4 -0
  30. package/dist/transcript.d.ts.map +1 -0
  31. package/dist/transcript.js +32 -0
  32. package/package.json +1 -1
  33. package/src/Chat.tsx +51 -9
  34. package/src/client-context.ts +160 -0
  35. package/src/components/ChatMessages.tsx +26 -0
  36. package/src/components/CopyTranscriptButton.tsx +58 -0
  37. package/src/components/DiffCard.tsx +252 -0
  38. package/src/components/index.ts +1 -0
  39. package/src/diff.ts +159 -0
  40. package/src/index.ts +39 -1
  41. package/src/transcript.ts +41 -0
@@ -0,0 +1,162 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useState } from 'react';
3
+ /**
4
+ * Portable diff visualization card.
5
+ *
6
+ * Renders a before/after comparison with word-level `<ins>` / `<del>` tags
7
+ * and Accept / Reject buttons. Pure React — no Gutenberg or WordPress
8
+ * dependencies. Works anywhere `@extrachill/chat` is consumed.
9
+ *
10
+ * @example
11
+ * ```tsx
12
+ * <DiffCard
13
+ * diff={{
14
+ * diffId: 'abc123',
15
+ * diffType: 'edit',
16
+ * originalContent: 'Hello world',
17
+ * replacementContent: 'Hello universe',
18
+ * }}
19
+ * onAccept={(id) => apiFetch({ path: `/resolve/${id}`, method: 'POST', data: { action: 'accept' } })}
20
+ * onReject={(id) => apiFetch({ path: `/resolve/${id}`, method: 'POST', data: { action: 'reject' } })}
21
+ * />
22
+ * ```
23
+ */
24
+ export function DiffCard({ diff, onAccept, onReject, loading = false, className, }) {
25
+ const [status, setStatus] = useState(diff.status ?? 'pending');
26
+ const baseClass = 'ec-chat-diff';
27
+ const classes = [
28
+ baseClass,
29
+ status !== 'pending' ? `${baseClass}--${status}` : '',
30
+ className,
31
+ ].filter(Boolean).join(' ');
32
+ const handleAccept = () => {
33
+ setStatus('accepted');
34
+ onAccept?.(diff.diffId);
35
+ };
36
+ const handleReject = () => {
37
+ setStatus('rejected');
38
+ onReject?.(diff.diffId);
39
+ };
40
+ const diffHtml = renderDiff(diff);
41
+ return (_jsxs("div", { className: classes, children: [_jsxs("div", { className: `${baseClass}__header`, children: [_jsx("span", { className: `${baseClass}__icon`, children: status === 'accepted' ? '✓' : status === 'rejected' ? '✗' : '⟳' }), _jsx("span", { className: `${baseClass}__label`, children: diff.summary ?? formatDiffLabel(diff.diffType) }), status !== 'pending' && (_jsx("span", { className: `${baseClass}__status`, children: status === 'accepted' ? 'Applied' : 'Rejected' }))] }), diff.insertionPoint && (_jsx("div", { className: `${baseClass}__meta`, children: diff.insertionPoint })), _jsx("div", { className: `${baseClass}__content`, dangerouslySetInnerHTML: { __html: diffHtml } }), status === 'pending' && (_jsxs("div", { className: `${baseClass}__actions`, children: [_jsx("button", { type: "button", className: `${baseClass}__accept`, onClick: handleAccept, disabled: loading, children: "Accept" }), _jsx("button", { type: "button", className: `${baseClass}__reject`, onClick: handleReject, disabled: loading, children: "Reject" })] }))] }));
42
+ }
43
+ /**
44
+ * Render diff HTML based on the diff type.
45
+ *
46
+ * For 'edit' diffs: word-level comparison between original and replacement.
47
+ * For 'replace' diffs: word-level comparison between original and replacement.
48
+ * For 'insert' diffs: everything is new (all `<ins>`).
49
+ */
50
+ function renderDiff(diff) {
51
+ const { diffType, originalContent, replacementContent } = diff;
52
+ if (diffType === 'insert') {
53
+ return `<ins class="ec-chat-diff__added">${escapeHtml(replacementContent)}</ins>`;
54
+ }
55
+ // Both 'edit' and 'replace' get word-level diff
56
+ return createWordLevelDiff(originalContent, replacementContent);
57
+ }
58
+ /**
59
+ * Create a word-level diff between two strings.
60
+ *
61
+ * Splits both strings into words, runs a longest-common-subsequence (LCS)
62
+ * alignment, and wraps removed words in `<del>` and added words in `<ins>`.
63
+ * Consecutive unchanged words are emitted as-is.
64
+ */
65
+ function createWordLevelDiff(oldText, newText) {
66
+ if (oldText === newText) {
67
+ return escapeHtml(newText);
68
+ }
69
+ const oldWords = tokenize(oldText);
70
+ const newWords = tokenize(newText);
71
+ // LCS table
72
+ const m = oldWords.length;
73
+ const n = newWords.length;
74
+ const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
75
+ for (let i = 1; i <= m; i++) {
76
+ for (let j = 1; j <= n; j++) {
77
+ if (oldWords[i - 1] === newWords[j - 1]) {
78
+ dp[i][j] = dp[i - 1][j - 1] + 1;
79
+ }
80
+ else {
81
+ dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
82
+ }
83
+ }
84
+ }
85
+ // Backtrack to produce diff operations
86
+ const ops = [];
87
+ let i = m;
88
+ let j = n;
89
+ while (i > 0 || j > 0) {
90
+ if (i > 0 && j > 0 && oldWords[i - 1] === newWords[j - 1]) {
91
+ ops.unshift({ type: 'keep', text: oldWords[i - 1] });
92
+ i--;
93
+ j--;
94
+ }
95
+ else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
96
+ ops.unshift({ type: 'ins', text: newWords[j - 1] });
97
+ j--;
98
+ }
99
+ else {
100
+ ops.unshift({ type: 'del', text: oldWords[i - 1] });
101
+ i--;
102
+ }
103
+ }
104
+ // Merge consecutive same-type ops into spans
105
+ let html = '';
106
+ let currentType = null;
107
+ let buffer = [];
108
+ const flush = () => {
109
+ if (buffer.length === 0)
110
+ return;
111
+ const text = buffer.join(' ');
112
+ if (currentType === 'del') {
113
+ html += `<del class="ec-chat-diff__removed">${escapeHtml(text)}</del>`;
114
+ }
115
+ else if (currentType === 'ins') {
116
+ html += `<ins class="ec-chat-diff__added">${escapeHtml(text)}</ins>`;
117
+ }
118
+ else {
119
+ html += escapeHtml(text);
120
+ }
121
+ buffer = [];
122
+ };
123
+ for (const op of ops) {
124
+ if (op.type !== currentType) {
125
+ flush();
126
+ currentType = op.type;
127
+ }
128
+ buffer.push(op.text);
129
+ }
130
+ flush();
131
+ return html;
132
+ }
133
+ /**
134
+ * Tokenize text into words, preserving whitespace as separate tokens
135
+ * so the diff output is readable.
136
+ */
137
+ function tokenize(text) {
138
+ return text.split(/(\s+)/).filter(Boolean);
139
+ }
140
+ /**
141
+ * Escape HTML special characters.
142
+ */
143
+ function escapeHtml(text) {
144
+ return text
145
+ .replace(/&/g, '&amp;')
146
+ .replace(/</g, '&lt;')
147
+ .replace(/>/g, '&gt;')
148
+ .replace(/"/g, '&quot;');
149
+ }
150
+ /**
151
+ * Human-readable label for a diff type.
152
+ */
153
+ function formatDiffLabel(diffType) {
154
+ switch (diffType) {
155
+ case 'edit':
156
+ return 'Content edit';
157
+ case 'replace':
158
+ return 'Content replacement';
159
+ case 'insert':
160
+ return 'New content';
161
+ }
162
+ }
@@ -21,7 +21,7 @@ export declare class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBo
21
21
  static getDerivedStateFromError(error: Error): ErrorBoundaryState;
22
22
  componentDidCatch(error: Error, errorInfo: ErrorInfo): void;
23
23
  reset: () => void;
24
- render(): string | number | boolean | Iterable<ReactNode> | import("react/jsx-runtime").JSX.Element | null | undefined;
24
+ render(): string | number | boolean | import("react/jsx-runtime").JSX.Element | Iterable<ReactNode> | null | undefined;
25
25
  }
26
26
  export {};
27
27
  //# sourceMappingURL=ErrorBoundary.d.ts.map
@@ -2,6 +2,7 @@ export { ChatMessage, type ChatMessageProps } from './ChatMessage.tsx';
2
2
  export { ChatMessages, type ChatMessagesProps } from './ChatMessages.tsx';
3
3
  export { ChatInput, type ChatInputProps } from './ChatInput.tsx';
4
4
  export { ToolMessage, type ToolMessageProps, type ToolGroup } from './ToolMessage.tsx';
5
+ export { DiffCard, type DiffCardProps, type DiffData } from './DiffCard.tsx';
5
6
  export { TypingIndicator, type TypingIndicatorProps } from './TypingIndicator.tsx';
6
7
  export { SessionSwitcher, type SessionSwitcherProps } from './SessionSwitcher.tsx';
7
8
  export { ErrorBoundary, type ErrorBoundaryProps } from './ErrorBoundary.tsx';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/components/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,KAAK,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AACvE,OAAO,EAAE,YAAY,EAAE,KAAK,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAC1E,OAAO,EAAE,SAAS,EAAE,KAAK,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACjE,OAAO,EAAE,WAAW,EAAE,KAAK,gBAAgB,EAAE,KAAK,SAAS,EAAE,MAAM,mBAAmB,CAAC;AACvF,OAAO,EAAE,eAAe,EAAE,KAAK,oBAAoB,EAAE,MAAM,uBAAuB,CAAC;AACnF,OAAO,EAAE,eAAe,EAAE,KAAK,oBAAoB,EAAE,MAAM,uBAAuB,CAAC;AACnF,OAAO,EAAE,aAAa,EAAE,KAAK,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AAC7E,OAAO,EAAE,gBAAgB,EAAE,KAAK,qBAAqB,EAAE,MAAM,wBAAwB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/components/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,KAAK,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AACvE,OAAO,EAAE,YAAY,EAAE,KAAK,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAC1E,OAAO,EAAE,SAAS,EAAE,KAAK,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACjE,OAAO,EAAE,WAAW,EAAE,KAAK,gBAAgB,EAAE,KAAK,SAAS,EAAE,MAAM,mBAAmB,CAAC;AACvF,OAAO,EAAE,QAAQ,EAAE,KAAK,aAAa,EAAE,KAAK,QAAQ,EAAE,MAAM,gBAAgB,CAAC;AAC7E,OAAO,EAAE,eAAe,EAAE,KAAK,oBAAoB,EAAE,MAAM,uBAAuB,CAAC;AACnF,OAAO,EAAE,eAAe,EAAE,KAAK,oBAAoB,EAAE,MAAM,uBAAuB,CAAC;AACnF,OAAO,EAAE,aAAa,EAAE,KAAK,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AAC7E,OAAO,EAAE,gBAAgB,EAAE,KAAK,qBAAqB,EAAE,MAAM,wBAAwB,CAAC"}
@@ -2,6 +2,7 @@ export { ChatMessage } from "./ChatMessage.js";
2
2
  export { ChatMessages } from "./ChatMessages.js";
3
3
  export { ChatInput } from "./ChatInput.js";
4
4
  export { ToolMessage } from "./ToolMessage.js";
5
+ export { DiffCard } from "./DiffCard.js";
5
6
  export { TypingIndicator } from "./TypingIndicator.js";
6
7
  export { SessionSwitcher } from "./SessionSwitcher.js";
7
8
  export { ErrorBoundary } from "./ErrorBoundary.js";
package/dist/diff.d.ts ADDED
@@ -0,0 +1,27 @@
1
+ import type { ToolGroup } from './components/ToolMessage.tsx';
2
+ export type CanonicalDiffType = 'edit' | 'replace' | 'insert';
3
+ export type CanonicalDiffStatus = 'pending' | 'accepted' | 'rejected';
4
+ export interface CanonicalDiffItem {
5
+ blockIndex?: number;
6
+ originalContent?: string;
7
+ replacementContent?: string;
8
+ }
9
+ export interface CanonicalDiffEditorData {
10
+ [key: string]: unknown;
11
+ }
12
+ export interface CanonicalDiffData {
13
+ diffId: string;
14
+ diffType: CanonicalDiffType;
15
+ originalContent: string;
16
+ replacementContent: string;
17
+ status?: CanonicalDiffStatus;
18
+ summary?: string;
19
+ items?: CanonicalDiffItem[];
20
+ position?: string;
21
+ insertionPoint?: string;
22
+ editor?: CanonicalDiffEditorData;
23
+ }
24
+ export declare function parseCanonicalDiff(value: unknown): CanonicalDiffData | null;
25
+ export declare function parseCanonicalDiffFromJson(json: string): CanonicalDiffData | null;
26
+ export declare function parseCanonicalDiffFromToolGroup(group: ToolGroup): CanonicalDiffData | null;
27
+ //# sourceMappingURL=diff.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"diff.d.ts","sourceRoot":"","sources":["../src/diff.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,8BAA8B,CAAC;AAE9D,MAAM,MAAM,iBAAiB,GAAG,MAAM,GAAG,SAAS,GAAG,QAAQ,CAAC;AAC9D,MAAM,MAAM,mBAAmB,GAAG,SAAS,GAAG,UAAU,GAAG,UAAU,CAAC;AAEtE,MAAM,WAAW,iBAAiB;IACjC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,kBAAkB,CAAC,EAAE,MAAM,CAAC;CAC5B;AAED,MAAM,WAAW,uBAAuB;IACvC,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACvB;AAED,MAAM,WAAW,iBAAiB;IACjC,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,iBAAiB,CAAC;IAC5B,eAAe,EAAE,MAAM,CAAC;IACxB,kBAAkB,EAAE,MAAM,CAAC;IAC3B,MAAM,CAAC,EAAE,mBAAmB,CAAC;IAC7B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,KAAK,CAAC,EAAE,iBAAiB,EAAE,CAAC;IAC5B,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,MAAM,CAAC,EAAE,uBAAuB,CAAC;CACjC;AA0CD,wBAAgB,kBAAkB,CAAE,KAAK,EAAE,OAAO,GAAI,iBAAiB,GAAG,IAAI,CA0E7E;AAED,wBAAgB,0BAA0B,CAAE,IAAI,EAAE,MAAM,GAAI,iBAAiB,GAAG,IAAI,CAMnF;AAED,wBAAgB,+BAA+B,CAAE,KAAK,EAAE,SAAS,GAAI,iBAAiB,GAAG,IAAI,CAM5F"}
package/dist/diff.js ADDED
@@ -0,0 +1,109 @@
1
+ function isRecord(value) {
2
+ return !!value && typeof value === 'object' && !Array.isArray(value);
3
+ }
4
+ function normalizeItem(item) {
5
+ if (!isRecord(item)) {
6
+ return null;
7
+ }
8
+ const blockIndex = typeof item.blockIndex === 'number'
9
+ ? item.blockIndex
10
+ : typeof item.block_index === 'number'
11
+ ? item.block_index
12
+ : undefined;
13
+ const originalContent = typeof item.originalContent === 'string'
14
+ ? item.originalContent
15
+ : typeof item.original_content === 'string'
16
+ ? item.original_content
17
+ : undefined;
18
+ const replacementContent = typeof item.replacementContent === 'string'
19
+ ? item.replacementContent
20
+ : typeof item.replacement_content === 'string'
21
+ ? item.replacement_content
22
+ : undefined;
23
+ if (blockIndex === undefined && originalContent === undefined && replacementContent === undefined) {
24
+ return null;
25
+ }
26
+ return {
27
+ blockIndex,
28
+ originalContent,
29
+ replacementContent,
30
+ };
31
+ }
32
+ export function parseCanonicalDiff(value) {
33
+ if (!isRecord(value)) {
34
+ return null;
35
+ }
36
+ const container = isRecord(value.data) ? value.data : value;
37
+ const rawDiff = isRecord(container.diff) ? container.diff : container;
38
+ const diffId = typeof rawDiff.diffId === 'string'
39
+ ? rawDiff.diffId
40
+ : typeof rawDiff.diff_id === 'string'
41
+ ? rawDiff.diff_id
42
+ : typeof container.diff_id === 'string'
43
+ ? container.diff_id
44
+ : '';
45
+ const diffType = rawDiff.diffType === 'replace' || rawDiff.diffType === 'insert'
46
+ ? rawDiff.diffType
47
+ : rawDiff.diffType === 'edit'
48
+ ? 'edit'
49
+ : rawDiff.diff_type === 'replace' || rawDiff.diff_type === 'insert'
50
+ ? rawDiff.diff_type
51
+ : 'edit';
52
+ const originalContent = typeof rawDiff.originalContent === 'string'
53
+ ? rawDiff.originalContent
54
+ : typeof rawDiff.original_content === 'string'
55
+ ? rawDiff.original_content
56
+ : '';
57
+ const replacementContent = typeof rawDiff.replacementContent === 'string'
58
+ ? rawDiff.replacementContent
59
+ : typeof rawDiff.replacement_content === 'string'
60
+ ? rawDiff.replacement_content
61
+ : '';
62
+ if (!diffId && !originalContent && !replacementContent) {
63
+ return null;
64
+ }
65
+ const itemsSource = Array.isArray(rawDiff.items)
66
+ ? rawDiff.items
67
+ : Array.isArray(rawDiff.edits)
68
+ ? rawDiff.edits
69
+ : Array.isArray(rawDiff.replacements)
70
+ ? rawDiff.replacements
71
+ : undefined;
72
+ const items = itemsSource
73
+ ?.map(normalizeItem)
74
+ .filter((item) => item !== null);
75
+ const summary = typeof rawDiff.summary === 'string'
76
+ ? rawDiff.summary
77
+ : typeof container.message === 'string'
78
+ ? container.message
79
+ : undefined;
80
+ const status = rawDiff.status === 'accepted' || rawDiff.status === 'rejected' || rawDiff.status === 'pending'
81
+ ? rawDiff.status
82
+ : undefined;
83
+ return {
84
+ diffId,
85
+ diffType,
86
+ originalContent,
87
+ replacementContent,
88
+ status,
89
+ summary,
90
+ items: items && items.length > 0 ? items : undefined,
91
+ position: typeof rawDiff.position === 'string' ? rawDiff.position : undefined,
92
+ insertionPoint: typeof rawDiff.insertionPoint === 'string' ? rawDiff.insertionPoint : undefined,
93
+ editor: isRecord(rawDiff.editor) ? rawDiff.editor : undefined,
94
+ };
95
+ }
96
+ export function parseCanonicalDiffFromJson(json) {
97
+ try {
98
+ return parseCanonicalDiff(JSON.parse(json));
99
+ }
100
+ catch {
101
+ return null;
102
+ }
103
+ }
104
+ export function parseCanonicalDiffFromToolGroup(group) {
105
+ if (!group.resultMessage) {
106
+ return null;
107
+ }
108
+ return parseCanonicalDiffFromJson(group.resultMessage.content);
109
+ }
package/dist/index.d.ts CHANGED
@@ -3,14 +3,19 @@ export type { FetchFn, FetchOptions, ChatApiConfig, SendResult, ContinueResult,
3
3
  export { sendMessage, continueResponse, listSessions, loadSession, deleteSession, } from './api.ts';
4
4
  export { normalizeMessage, normalizeConversation, normalizeSession } from './normalizer.ts';
5
5
  export { markdownToHtml } from './markdown.ts';
6
+ export { formatChatAsMarkdown, copyChatAsMarkdown } from './transcript.ts';
7
+ export { parseCanonicalDiff, parseCanonicalDiffFromJson, parseCanonicalDiffFromToolGroup, type CanonicalDiffData, type CanonicalDiffEditorData, type CanonicalDiffItem, type CanonicalDiffStatus, type CanonicalDiffType, } from './diff.ts';
8
+ export { getOrCreateClientContextRegistry, registerClientContextProvider, getClientContextMetadata, useClientContextMetadata, type ClientContextProvider, type ClientContextProviderSnapshot, type ClientContextSnapshot, type ClientContextRegistry, } from './client-context.ts';
6
9
  export { ChatMessage as ChatMessageComponent, type ChatMessageProps, } from './components/ChatMessage.tsx';
7
10
  export { ChatMessages, type ChatMessagesProps, } from './components/ChatMessages.tsx';
8
11
  export { ChatInput, type ChatInputProps, } from './components/ChatInput.tsx';
12
+ export { CopyTranscriptButton, type CopyTranscriptButtonProps, } from './components/CopyTranscriptButton.tsx';
9
13
  export { ToolMessage, type ToolMessageProps, type ToolGroup, } from './components/ToolMessage.tsx';
14
+ export { DiffCard, type DiffCardProps, type DiffData, } from './components/DiffCard.tsx';
10
15
  export { TypingIndicator, type TypingIndicatorProps, } from './components/TypingIndicator.tsx';
11
16
  export { SessionSwitcher, type SessionSwitcherProps, } from './components/SessionSwitcher.tsx';
12
17
  export { ErrorBoundary, type ErrorBoundaryProps, } from './components/ErrorBoundary.tsx';
13
18
  export { AvailabilityGate, type AvailabilityGateProps, } from './components/AvailabilityGate.tsx';
14
19
  export { useChat, type UseChatOptions, type UseChatReturn, } from './hooks/useChat.ts';
15
- export { Chat, type ChatProps } from './Chat.tsx';
20
+ export { Chat, type ChatProps, type ChatSessionUi } from './Chat.tsx';
16
21
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,YAAY,EACX,WAAW,EACX,QAAQ,EACR,cAAc,EACd,eAAe,EACf,WAAW,EACX,aAAa,EACb,WAAW,EACX,gBAAgB,EAChB,gBAAgB,EAChB,aAAa,EACb,UAAU,EACV,UAAU,EACV,eAAe,GACf,MAAM,kBAAkB,CAAC;AAG1B,YAAY,EAAE,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,UAAU,EAAE,cAAc,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AACjH,OAAO,EACN,WAAW,EACX,gBAAgB,EAChB,YAAY,EACZ,WAAW,EACX,aAAa,GACb,MAAM,UAAU,CAAC;AAGlB,OAAO,EAAE,gBAAgB,EAAE,qBAAqB,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AAG5F,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAG/C,OAAO,EACN,WAAW,IAAI,oBAAoB,EACnC,KAAK,gBAAgB,GACrB,MAAM,8BAA8B,CAAC;AAEtC,OAAO,EACN,YAAY,EACZ,KAAK,iBAAiB,GACtB,MAAM,+BAA+B,CAAC;AAEvC,OAAO,EACN,SAAS,EACT,KAAK,cAAc,GACnB,MAAM,4BAA4B,CAAC;AAEpC,OAAO,EACN,WAAW,EACX,KAAK,gBAAgB,EACrB,KAAK,SAAS,GACd,MAAM,8BAA8B,CAAC;AAEtC,OAAO,EACN,eAAe,EACf,KAAK,oBAAoB,GACzB,MAAM,kCAAkC,CAAC;AAE1C,OAAO,EACN,eAAe,EACf,KAAK,oBAAoB,GACzB,MAAM,kCAAkC,CAAC;AAE1C,OAAO,EACN,aAAa,EACb,KAAK,kBAAkB,GACvB,MAAM,gCAAgC,CAAC;AAExC,OAAO,EACN,gBAAgB,EAChB,KAAK,qBAAqB,GAC1B,MAAM,mCAAmC,CAAC;AAG3C,OAAO,EACN,OAAO,EACP,KAAK,cAAc,EACnB,KAAK,aAAa,GAClB,MAAM,oBAAoB,CAAC;AAG5B,OAAO,EAAE,IAAI,EAAE,KAAK,SAAS,EAAE,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,YAAY,EACX,WAAW,EACX,QAAQ,EACR,cAAc,EACd,eAAe,EACf,WAAW,EACX,aAAa,EACb,WAAW,EACX,gBAAgB,EAChB,gBAAgB,EAChB,aAAa,EACb,UAAU,EACV,UAAU,EACV,eAAe,GACf,MAAM,kBAAkB,CAAC;AAG1B,YAAY,EAAE,OAAO,EAAE,YAAY,EAAE,aAAa,EAAE,UAAU,EAAE,cAAc,EAAE,cAAc,EAAE,MAAM,UAAU,CAAC;AACjH,OAAO,EACN,WAAW,EACX,gBAAgB,EAChB,YAAY,EACZ,WAAW,EACX,aAAa,GACb,MAAM,UAAU,CAAC;AAGlB,OAAO,EAAE,gBAAgB,EAAE,qBAAqB,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;AAG5F,OAAO,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAG/C,OAAO,EAAE,oBAAoB,EAAE,kBAAkB,EAAE,MAAM,iBAAiB,CAAC;AAG3E,OAAO,EACN,kBAAkB,EAClB,0BAA0B,EAC1B,+BAA+B,EAC/B,KAAK,iBAAiB,EACtB,KAAK,uBAAuB,EAC5B,KAAK,iBAAiB,EACtB,KAAK,mBAAmB,EACxB,KAAK,iBAAiB,GACtB,MAAM,WAAW,CAAC;AAGnB,OAAO,EACN,gCAAgC,EAChC,6BAA6B,EAC7B,wBAAwB,EACxB,wBAAwB,EACxB,KAAK,qBAAqB,EAC1B,KAAK,6BAA6B,EAClC,KAAK,qBAAqB,EAC1B,KAAK,qBAAqB,GAC1B,MAAM,qBAAqB,CAAC;AAG7B,OAAO,EACN,WAAW,IAAI,oBAAoB,EACnC,KAAK,gBAAgB,GACrB,MAAM,8BAA8B,CAAC;AAEtC,OAAO,EACN,YAAY,EACZ,KAAK,iBAAiB,GACtB,MAAM,+BAA+B,CAAC;AAEvC,OAAO,EACN,SAAS,EACT,KAAK,cAAc,GACnB,MAAM,4BAA4B,CAAC;AAEpC,OAAO,EACN,oBAAoB,EACpB,KAAK,yBAAyB,GAC9B,MAAM,uCAAuC,CAAC;AAE/C,OAAO,EACN,WAAW,EACX,KAAK,gBAAgB,EACrB,KAAK,SAAS,GACd,MAAM,8BAA8B,CAAC;AAEtC,OAAO,EACN,QAAQ,EACR,KAAK,aAAa,EAClB,KAAK,QAAQ,GACb,MAAM,2BAA2B,CAAC;AAEnC,OAAO,EACN,eAAe,EACf,KAAK,oBAAoB,GACzB,MAAM,kCAAkC,CAAC;AAE1C,OAAO,EACN,eAAe,EACf,KAAK,oBAAoB,GACzB,MAAM,kCAAkC,CAAC;AAE1C,OAAO,EACN,aAAa,EACb,KAAK,kBAAkB,GACvB,MAAM,gCAAgC,CAAC;AAExC,OAAO,EACN,gBAAgB,EAChB,KAAK,qBAAqB,GAC1B,MAAM,mCAAmC,CAAC;AAG3C,OAAO,EACN,OAAO,EACP,KAAK,cAAc,EACnB,KAAK,aAAa,GAClB,MAAM,oBAAoB,CAAC;AAG5B,OAAO,EAAE,IAAI,EAAE,KAAK,SAAS,EAAE,KAAK,aAAa,EAAE,MAAM,YAAY,CAAC"}
package/dist/index.js CHANGED
@@ -3,11 +3,19 @@ export { sendMessage, continueResponse, listSessions, loadSession, deleteSession
3
3
  export { normalizeMessage, normalizeConversation, normalizeSession } from "./normalizer.js";
4
4
  // Markdown
5
5
  export { markdownToHtml } from "./markdown.js";
6
+ // Transcript
7
+ export { formatChatAsMarkdown, copyChatAsMarkdown } from "./transcript.js";
8
+ // Diff helpers
9
+ export { parseCanonicalDiff, parseCanonicalDiffFromJson, parseCanonicalDiffFromToolGroup, } from "./diff.js";
10
+ // Client context
11
+ export { getOrCreateClientContextRegistry, registerClientContextProvider, getClientContextMetadata, useClientContextMetadata, } from "./client-context.js";
6
12
  // Components
7
13
  export { ChatMessage as ChatMessageComponent, } from "./components/ChatMessage.js";
8
14
  export { ChatMessages, } from "./components/ChatMessages.js";
9
15
  export { ChatInput, } from "./components/ChatInput.js";
16
+ export { CopyTranscriptButton, } from "./components/CopyTranscriptButton.js";
10
17
  export { ToolMessage, } from "./components/ToolMessage.js";
18
+ export { DiffCard, } from "./components/DiffCard.js";
11
19
  export { TypingIndicator, } from "./components/TypingIndicator.js";
12
20
  export { SessionSwitcher, } from "./components/SessionSwitcher.js";
13
21
  export { ErrorBoundary, } from "./components/ErrorBoundary.js";
@@ -0,0 +1,4 @@
1
+ import type { ChatMessage } from './types/index.ts';
2
+ export declare function formatChatAsMarkdown(messages: ChatMessage[]): string;
3
+ export declare function copyChatAsMarkdown(messages: ChatMessage[]): Promise<string>;
4
+ //# sourceMappingURL=transcript.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"transcript.d.ts","sourceRoot":"","sources":["../src/transcript.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAEpD,wBAAgB,oBAAoB,CAAE,QAAQ,EAAE,WAAW,EAAE,GAAI,MAAM,CA0BtE;AAED,wBAAsB,kBAAkB,CAAE,QAAQ,EAAE,WAAW,EAAE,GAAI,OAAO,CAAE,MAAM,CAAE,CAUrF"}
@@ -0,0 +1,32 @@
1
+ export function formatChatAsMarkdown(messages) {
2
+ return messages
3
+ .filter((message) => {
4
+ if (message.role === 'system' || message.role === 'tool_call') {
5
+ return false;
6
+ }
7
+ return true;
8
+ })
9
+ .map((message) => {
10
+ const timestamp = message.timestamp
11
+ ? new Date(message.timestamp).toLocaleString()
12
+ : '';
13
+ const timestampStr = timestamp ? ` (${timestamp})` : '';
14
+ if (message.role === 'tool_result') {
15
+ const toolName = message.toolResult?.toolName ?? 'Tool';
16
+ const success = message.toolResult?.success;
17
+ const status = success === false ? 'FAILED' : 'SUCCESS';
18
+ return `**Tool Response (${toolName} - ${status})${timestampStr}:**\n${message.content}`;
19
+ }
20
+ const role = message.role === 'user' ? 'User' : 'Assistant';
21
+ return `**${role}${timestampStr}:**\n${message.content}`;
22
+ })
23
+ .join('\n\n---\n\n');
24
+ }
25
+ export async function copyChatAsMarkdown(messages) {
26
+ const markdown = formatChatAsMarkdown(messages);
27
+ if (typeof navigator === 'undefined' || !navigator.clipboard?.writeText) {
28
+ throw new Error('Clipboard API is not available.');
29
+ }
30
+ await navigator.clipboard.writeText(markdown);
31
+ return markdown;
32
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@extrachill/chat",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "Chat UI components with built-in REST API client. Speaks the standard chat message format natively.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
package/src/Chat.tsx CHANGED
@@ -1,6 +1,7 @@
1
1
  import { type ReactNode } from 'react';
2
2
  import type { ChatMessage as ChatMessageType, ContentFormat } from './types/index.ts';
3
3
  import type { FetchFn } from './api.ts';
4
+ import type { ToolGroup } from './components/ToolMessage.tsx';
4
5
  import { useChat, type UseChatOptions } from './hooks/useChat.ts';
5
6
  import { ErrorBoundary } from './components/ErrorBoundary.tsx';
6
7
  import { AvailabilityGate } from './components/AvailabilityGate.tsx';
@@ -8,6 +9,10 @@ import { ChatMessages } from './components/ChatMessages.tsx';
8
9
  import { ChatInput } from './components/ChatInput.tsx';
9
10
  import { TypingIndicator } from './components/TypingIndicator.tsx';
10
11
  import { SessionSwitcher } from './components/SessionSwitcher.tsx';
12
+ import { CopyTranscriptButton } from './components/CopyTranscriptButton.tsx';
13
+ import type { UseChatReturn } from './hooks/useChat.ts';
14
+
15
+ export type ChatSessionUi = 'list' | 'none';
11
16
 
12
17
  export interface ChatProps {
13
18
  /**
@@ -32,6 +37,14 @@ export interface ChatProps {
32
37
  showTools?: boolean;
33
38
  /** Map of tool function names to friendly display labels. */
34
39
  toolNames?: Record<string, string>;
40
+ /**
41
+ * Custom renderers for specific tool names.
42
+ *
43
+ * Map tool function names to React render functions. When a tool group
44
+ * matches a registered name, the custom renderer is used instead of
45
+ * the default ToolMessage JSON display.
46
+ */
47
+ toolRenderers?: Record<string, (group: ToolGroup) => ReactNode>;
35
48
  /** Placeholder text for the input. */
36
49
  placeholder?: string;
37
50
  /** Content shown when conversation is empty. */
@@ -50,6 +63,8 @@ export interface ChatProps {
50
63
  className?: string;
51
64
  /** Whether to show the session switcher. Defaults to true. */
52
65
  showSessions?: boolean;
66
+ /** Session UI mode. 'list' renders the built-in switcher, 'none' lets the consumer render its own. */
67
+ sessionUi?: ChatSessionUi;
53
68
  /** Label shown during multi-turn processing. */
54
69
  processingLabel?: (turnCount: number) => string;
55
70
  /** Whether to show the attachment button in the input. Defaults to true. */
@@ -61,6 +76,14 @@ export interface ChatProps {
61
76
  * Use for client-side context injection (e.g. `{ client_context: { tab: 'compose', postId: 123 } }`).
62
77
  */
63
78
  metadata?: Record<string, unknown>;
79
+ /** Whether to show a built-in copy transcript button. Defaults to false. */
80
+ showCopyTranscript?: boolean;
81
+ /** Label for the built-in copy transcript button. */
82
+ copyTranscriptLabel?: string;
83
+ /** Label shown after the transcript is copied. */
84
+ copyTranscriptCopiedLabel?: string;
85
+ /** Optional custom header/actions area rendered above messages with live chat state. */
86
+ renderHeader?: ( chat: UseChatReturn ) => ReactNode;
64
87
  }
65
88
 
66
89
  /**
@@ -94,6 +117,7 @@ export function Chat({
94
117
  renderContent,
95
118
  showTools = true,
96
119
  toolNames,
120
+ toolRenderers,
97
121
  placeholder,
98
122
  emptyState,
99
123
  initialMessages,
@@ -103,10 +127,15 @@ export function Chat({
103
127
  onMessage,
104
128
  className,
105
129
  showSessions = true,
130
+ sessionUi = 'list',
106
131
  processingLabel,
107
132
  allowAttachments = true,
108
133
  acceptFileTypes,
109
134
  metadata,
135
+ showCopyTranscript = false,
136
+ copyTranscriptLabel,
137
+ copyTranscriptCopiedLabel,
138
+ renderHeader,
110
139
  }: ChatProps) {
111
140
  const chat = useChat({
112
141
  basePath,
@@ -127,7 +156,19 @@ export function Chat({
127
156
  <ErrorBoundary onError={onError ? (err) => onError(err) : undefined}>
128
157
  <div className={classes}>
129
158
  <AvailabilityGate availability={chat.availability}>
130
- {showSessions && (
159
+ {renderHeader?.( chat )}
160
+
161
+ {showCopyTranscript && (
162
+ <div className="ec-chat__actions">
163
+ <CopyTranscriptButton
164
+ messages={chat.messages}
165
+ label={copyTranscriptLabel}
166
+ copiedLabel={copyTranscriptCopiedLabel}
167
+ />
168
+ </div>
169
+ )}
170
+
171
+ {showSessions && sessionUi === 'list' && (
131
172
  <SessionSwitcher
132
173
  sessions={chat.sessions}
133
174
  activeSessionId={chat.sessionId ?? undefined}
@@ -138,14 +179,15 @@ export function Chat({
138
179
  />
139
180
  )}
140
181
 
141
- <ChatMessages
142
- messages={chat.messages}
143
- contentFormat={contentFormat}
144
- renderContent={renderContent}
145
- showTools={showTools}
146
- toolNames={toolNames}
147
- emptyState={emptyState}
148
- />
182
+ <ChatMessages
183
+ messages={chat.messages}
184
+ contentFormat={contentFormat}
185
+ renderContent={renderContent}
186
+ showTools={showTools}
187
+ toolNames={toolNames}
188
+ toolRenderers={toolRenderers}
189
+ emptyState={emptyState}
190
+ />
149
191
 
150
192
  <TypingIndicator
151
193
  visible={chat.isLoading}