@extrachill/chat 0.5.0 → 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 +10 -0
  2. package/README.md +1 -1
  3. package/css/chat.css +184 -1
  4. package/dist/Chat.d.ts +27 -1
  5. package/dist/Chat.d.ts.map +1 -1
  6. package/dist/Chat.js +4 -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 +58 -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.0",
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,12 +63,27 @@ 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. */
56
71
  allowAttachments?: boolean;
57
72
  /** Accepted file types for attachments. Defaults to 'image/*,video/*'. */
58
73
  acceptFileTypes?: string;
74
+ /**
75
+ * Arbitrary metadata forwarded to the backend with each message.
76
+ * Use for client-side context injection (e.g. `{ client_context: { tab: 'compose', postId: 123 } }`).
77
+ */
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;
59
87
  }
60
88
 
61
89
  /**
@@ -89,6 +117,7 @@ export function Chat({
89
117
  renderContent,
90
118
  showTools = true,
91
119
  toolNames,
120
+ toolRenderers,
92
121
  placeholder,
93
122
  emptyState,
94
123
  initialMessages,
@@ -98,9 +127,15 @@ export function Chat({
98
127
  onMessage,
99
128
  className,
100
129
  showSessions = true,
130
+ sessionUi = 'list',
101
131
  processingLabel,
102
132
  allowAttachments = true,
103
133
  acceptFileTypes,
134
+ metadata,
135
+ showCopyTranscript = false,
136
+ copyTranscriptLabel,
137
+ copyTranscriptCopiedLabel,
138
+ renderHeader,
104
139
  }: ChatProps) {
105
140
  const chat = useChat({
106
141
  basePath,
@@ -111,6 +146,7 @@ export function Chat({
111
146
  maxContinueTurns,
112
147
  onError,
113
148
  onMessage,
149
+ metadata,
114
150
  });
115
151
 
116
152
  const baseClass = 'ec-chat';
@@ -120,7 +156,19 @@ export function Chat({
120
156
  <ErrorBoundary onError={onError ? (err) => onError(err) : undefined}>
121
157
  <div className={classes}>
122
158
  <AvailabilityGate availability={chat.availability}>
123
- {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' && (
124
172
  <SessionSwitcher
125
173
  sessions={chat.sessions}
126
174
  activeSessionId={chat.sessionId ?? undefined}
@@ -131,14 +179,15 @@ export function Chat({
131
179
  />
132
180
  )}
133
181
 
134
- <ChatMessages
135
- messages={chat.messages}
136
- contentFormat={contentFormat}
137
- renderContent={renderContent}
138
- showTools={showTools}
139
- toolNames={toolNames}
140
- emptyState={emptyState}
141
- />
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
+ />
142
191
 
143
192
  <TypingIndicator
144
193
  visible={chat.isLoading}