@extrachill/chat 0.5.1 → 0.7.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 +15 -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,160 @@
1
+ import { useEffect, useMemo, useState } from 'react';
2
+
3
+ export interface ClientContextProvider {
4
+ id: string;
5
+ priority?: number;
6
+ getContext: () => Record< string, unknown > | null;
7
+ }
8
+
9
+ export interface ClientContextProviderSnapshot {
10
+ id: string;
11
+ priority: number;
12
+ context: Record< string, unknown >;
13
+ }
14
+
15
+ export interface ClientContextSnapshot {
16
+ activeContext: Record< string, unknown > | null;
17
+ providers: ClientContextProviderSnapshot[];
18
+ }
19
+
20
+ export interface ClientContextRegistry {
21
+ registerProvider: ( provider: ClientContextProvider ) => () => void;
22
+ unregisterProvider: ( id: string ) => void;
23
+ subscribe: ( listener: () => void ) => () => void;
24
+ notify: () => void;
25
+ getSnapshot: () => ClientContextSnapshot;
26
+ }
27
+
28
+ declare global {
29
+ interface Window {
30
+ ecClientContextRegistry?: ClientContextRegistry;
31
+ }
32
+ }
33
+
34
+ export function getOrCreateClientContextRegistry(): ClientContextRegistry {
35
+ if ( typeof window === 'undefined' ) {
36
+ return {
37
+ registerProvider: () => () => undefined,
38
+ unregisterProvider: () => undefined,
39
+ subscribe: () => () => undefined,
40
+ notify: () => undefined,
41
+ getSnapshot: () => ( {
42
+ activeContext: null,
43
+ providers: [],
44
+ } ),
45
+ };
46
+ }
47
+
48
+ if ( window.ecClientContextRegistry ) {
49
+ return window.ecClientContextRegistry;
50
+ }
51
+
52
+ const providers = new Map< string, ClientContextProvider >();
53
+ const listeners = new Set< () => void >();
54
+
55
+ const registry: ClientContextRegistry = {
56
+ registerProvider( provider ) {
57
+ providers.set( provider.id, provider );
58
+ registry.notify();
59
+
60
+ return () => {
61
+ registry.unregisterProvider( provider.id );
62
+ };
63
+ },
64
+
65
+ unregisterProvider( id ) {
66
+ if ( providers.delete( id ) ) {
67
+ registry.notify();
68
+ }
69
+ },
70
+
71
+ subscribe( listener ) {
72
+ listeners.add( listener );
73
+
74
+ return () => {
75
+ listeners.delete( listener );
76
+ };
77
+ },
78
+
79
+ notify() {
80
+ listeners.forEach( ( listener ) => listener() );
81
+ },
82
+
83
+ getSnapshot() {
84
+ const providerSnapshots = Array.from( providers.values() )
85
+ .map( ( provider ) => {
86
+ const context = provider.getContext();
87
+ if ( ! context ) {
88
+ return null;
89
+ }
90
+
91
+ return {
92
+ id: provider.id,
93
+ priority: provider.priority ?? 0,
94
+ context,
95
+ };
96
+ } )
97
+ .filter(
98
+ (
99
+ provider
100
+ ): provider is ClientContextProviderSnapshot => Boolean( provider )
101
+ )
102
+ .sort( ( a, b ) => b.priority - a.priority );
103
+
104
+ return {
105
+ activeContext: providerSnapshots[ 0 ]?.context ?? null,
106
+ providers: providerSnapshots,
107
+ };
108
+ },
109
+ };
110
+
111
+ window.ecClientContextRegistry = registry;
112
+
113
+ return registry;
114
+ }
115
+
116
+ export function registerClientContextProvider( provider: ClientContextProvider ): () => void {
117
+ return getOrCreateClientContextRegistry().registerProvider( provider );
118
+ }
119
+
120
+ export function getClientContextMetadata(): Record< string, unknown > {
121
+ if ( typeof window === 'undefined' ) {
122
+ return {};
123
+ }
124
+
125
+ const snapshot = getOrCreateClientContextRegistry().getSnapshot();
126
+
127
+ return {
128
+ client_context: {
129
+ site: window.location.hostname,
130
+ url: window.location.href,
131
+ path: window.location.pathname,
132
+ page_title: document.title,
133
+ active_context: snapshot.activeContext,
134
+ available_contexts: snapshot.providers.map( ( provider ) => ( {
135
+ id: provider.id,
136
+ priority: provider.priority,
137
+ context: provider.context,
138
+ } ) ),
139
+ },
140
+ };
141
+ }
142
+
143
+ export function useClientContextMetadata(): Record< string, unknown > {
144
+ const registry = useMemo( () => getOrCreateClientContextRegistry(), [] );
145
+ const [ metadata, setMetadata ] = useState< Record< string, unknown > >( () =>
146
+ getClientContextMetadata()
147
+ );
148
+
149
+ useEffect( () => {
150
+ const sync = (): void => {
151
+ setMetadata( getClientContextMetadata() );
152
+ };
153
+
154
+ sync();
155
+
156
+ return registry.subscribe( sync );
157
+ }, [ registry ] );
158
+
159
+ return metadata;
160
+ }
@@ -14,6 +14,22 @@ export interface ChatMessagesProps {
14
14
  showTools?: boolean;
15
15
  /** Custom tool name display map. Maps tool function names to friendly labels. */
16
16
  toolNames?: Record<string, string>;
17
+ /**
18
+ * Custom renderers for specific tool names.
19
+ *
20
+ * Map tool function names to React render functions. When a tool group
21
+ * matches a registered name, the custom renderer is used instead of
22
+ * the default `ToolMessage` JSON display.
23
+ *
24
+ * @example
25
+ * ```tsx
26
+ * toolRenderers={{
27
+ * edit_post_blocks: (group) => <DiffCard diff={parseDiff(group)} />,
28
+ * replace_post_blocks: (group) => <DiffCard diff={parseDiff(group)} />,
29
+ * }}
30
+ * ```
31
+ */
32
+ toolRenderers?: Record<string, (group: ToolGroup) => ReactNode>;
17
33
  /** Whether to auto-scroll to bottom on new messages. Defaults to true. */
18
34
  autoScroll?: boolean;
19
35
  /** Placeholder content shown when there are no messages. */
@@ -34,6 +50,7 @@ export function ChatMessages({
34
50
  renderContent,
35
51
  showTools = false,
36
52
  toolNames,
53
+ toolRenderers,
37
54
  autoScroll = true,
38
55
  emptyState,
39
56
  className,
@@ -81,6 +98,15 @@ export function ChatMessages({
81
98
  }
82
99
 
83
100
  if (item.type === 'tool-group' && showTools) {
101
+ const customRenderer = toolRenderers?.[item.group.toolName];
102
+ if (customRenderer) {
103
+ return (
104
+ <div key={item.group.callMessage.id}>
105
+ {customRenderer(item.group)}
106
+ </div>
107
+ );
108
+ }
109
+
84
110
  return (
85
111
  <ToolMessage
86
112
  key={item.group.callMessage.id}
@@ -0,0 +1,58 @@
1
+ import { useCallback, useEffect, useState } from 'react';
2
+ import type { ChatMessage } from '../types/index.ts';
3
+ import { copyChatAsMarkdown } from '../transcript.ts';
4
+
5
+ export interface CopyTranscriptButtonProps {
6
+ messages: ChatMessage[];
7
+ label?: string;
8
+ copiedLabel?: string;
9
+ className?: string;
10
+ copyTimeoutMs?: number;
11
+ onCopy?: ( markdown: string ) => void;
12
+ onError?: ( error: Error ) => void;
13
+ }
14
+
15
+ export function CopyTranscriptButton( {
16
+ messages,
17
+ label = 'Copy',
18
+ copiedLabel = 'Copied!',
19
+ className,
20
+ copyTimeoutMs = 2000,
21
+ onCopy,
22
+ onError,
23
+ }: CopyTranscriptButtonProps ) {
24
+ const [ isCopied, setIsCopied ] = useState( false );
25
+
26
+ useEffect( () => {
27
+ if ( ! isCopied ) {
28
+ return undefined;
29
+ }
30
+
31
+ const timer = window.setTimeout( () => setIsCopied( false ), copyTimeoutMs );
32
+ return () => window.clearTimeout( timer );
33
+ }, [ isCopied, copyTimeoutMs ] );
34
+
35
+ const handleClick = useCallback( async () => {
36
+ try {
37
+ const markdown = await copyChatAsMarkdown( messages );
38
+ setIsCopied( true );
39
+ onCopy?.( markdown );
40
+ } catch ( error ) {
41
+ onError?.( error as Error );
42
+ }
43
+ }, [ messages, onCopy, onError ] );
44
+
45
+ const baseClass = 'ec-chat-copy';
46
+ const classes = [ baseClass, className ].filter( Boolean ).join( ' ' );
47
+
48
+ return (
49
+ <button
50
+ type="button"
51
+ className={ classes }
52
+ onClick={ handleClick }
53
+ disabled={ messages.length === 0 }
54
+ >
55
+ { isCopied ? copiedLabel : label }
56
+ </button>
57
+ );
58
+ }
@@ -0,0 +1,252 @@
1
+ import { useState, type ReactNode } from 'react';
2
+ import type { CanonicalDiffData } from '../diff.ts';
3
+
4
+ /**
5
+ * Diff data returned by a content-editing tool in preview mode.
6
+ */
7
+ export type DiffData = CanonicalDiffData;
8
+
9
+ export interface DiffCardProps {
10
+ /** The diff data to visualize. */
11
+ diff: DiffData;
12
+ /** Called when the user accepts the change. */
13
+ onAccept?: (diffId: string) => void;
14
+ /** Called when the user rejects the change. */
15
+ onReject?: (diffId: string) => void;
16
+ /** Whether the accept/reject action is in progress. */
17
+ loading?: boolean;
18
+ /** Additional CSS class name. */
19
+ className?: string;
20
+ }
21
+
22
+ type DiffCardStatus = 'pending' | 'accepted' | 'rejected';
23
+
24
+ /**
25
+ * Portable diff visualization card.
26
+ *
27
+ * Renders a before/after comparison with word-level `<ins>` / `<del>` tags
28
+ * and Accept / Reject buttons. Pure React — no Gutenberg or WordPress
29
+ * dependencies. Works anywhere `@extrachill/chat` is consumed.
30
+ *
31
+ * @example
32
+ * ```tsx
33
+ * <DiffCard
34
+ * diff={{
35
+ * diffId: 'abc123',
36
+ * diffType: 'edit',
37
+ * originalContent: 'Hello world',
38
+ * replacementContent: 'Hello universe',
39
+ * }}
40
+ * onAccept={(id) => apiFetch({ path: `/resolve/${id}`, method: 'POST', data: { action: 'accept' } })}
41
+ * onReject={(id) => apiFetch({ path: `/resolve/${id}`, method: 'POST', data: { action: 'reject' } })}
42
+ * />
43
+ * ```
44
+ */
45
+ export function DiffCard({
46
+ diff,
47
+ onAccept,
48
+ onReject,
49
+ loading = false,
50
+ className,
51
+ }: DiffCardProps) {
52
+ const [status, setStatus] = useState<DiffCardStatus>(diff.status ?? 'pending');
53
+
54
+ const baseClass = 'ec-chat-diff';
55
+ const classes = [
56
+ baseClass,
57
+ status !== 'pending' ? `${baseClass}--${status}` : '',
58
+ className,
59
+ ].filter(Boolean).join(' ');
60
+
61
+ const handleAccept = () => {
62
+ setStatus('accepted');
63
+ onAccept?.(diff.diffId);
64
+ };
65
+
66
+ const handleReject = () => {
67
+ setStatus('rejected');
68
+ onReject?.(diff.diffId);
69
+ };
70
+
71
+ const diffHtml = renderDiff(diff);
72
+
73
+ return (
74
+ <div className={classes}>
75
+ <div className={`${baseClass}__header`}>
76
+ <span className={`${baseClass}__icon`}>
77
+ {status === 'accepted' ? '✓' : status === 'rejected' ? '✗' : '⟳'}
78
+ </span>
79
+ <span className={`${baseClass}__label`}>
80
+ {diff.summary ?? formatDiffLabel(diff.diffType)}
81
+ </span>
82
+ {status !== 'pending' && (
83
+ <span className={`${baseClass}__status`}>
84
+ {status === 'accepted' ? 'Applied' : 'Rejected'}
85
+ </span>
86
+ )}
87
+ </div>
88
+
89
+ {diff.insertionPoint && (
90
+ <div className={`${baseClass}__meta`}>
91
+ {diff.insertionPoint}
92
+ </div>
93
+ )}
94
+
95
+ <div
96
+ className={`${baseClass}__content`}
97
+ dangerouslySetInnerHTML={{ __html: diffHtml }}
98
+ />
99
+
100
+ {status === 'pending' && (
101
+ <div className={`${baseClass}__actions`}>
102
+ <button
103
+ type="button"
104
+ className={`${baseClass}__accept`}
105
+ onClick={handleAccept}
106
+ disabled={loading}
107
+ >
108
+ Accept
109
+ </button>
110
+ <button
111
+ type="button"
112
+ className={`${baseClass}__reject`}
113
+ onClick={handleReject}
114
+ disabled={loading}
115
+ >
116
+ Reject
117
+ </button>
118
+ </div>
119
+ )}
120
+ </div>
121
+ );
122
+ }
123
+
124
+ /**
125
+ * Render diff HTML based on the diff type.
126
+ *
127
+ * For 'edit' diffs: word-level comparison between original and replacement.
128
+ * For 'replace' diffs: word-level comparison between original and replacement.
129
+ * For 'insert' diffs: everything is new (all `<ins>`).
130
+ */
131
+ function renderDiff(diff: DiffData): string {
132
+ const { diffType, originalContent, replacementContent } = diff;
133
+
134
+ if (diffType === 'insert') {
135
+ return `<ins class="ec-chat-diff__added">${escapeHtml(replacementContent)}</ins>`;
136
+ }
137
+
138
+ // Both 'edit' and 'replace' get word-level diff
139
+ return createWordLevelDiff(originalContent, replacementContent);
140
+ }
141
+
142
+ /**
143
+ * Create a word-level diff between two strings.
144
+ *
145
+ * Splits both strings into words, runs a longest-common-subsequence (LCS)
146
+ * alignment, and wraps removed words in `<del>` and added words in `<ins>`.
147
+ * Consecutive unchanged words are emitted as-is.
148
+ */
149
+ function createWordLevelDiff(oldText: string, newText: string): string {
150
+ if (oldText === newText) {
151
+ return escapeHtml(newText);
152
+ }
153
+
154
+ const oldWords = tokenize(oldText);
155
+ const newWords = tokenize(newText);
156
+
157
+ // LCS table
158
+ const m = oldWords.length;
159
+ const n = newWords.length;
160
+ const dp: number[][] = Array.from({ length: m + 1 }, () => new Array<number>(n + 1).fill(0));
161
+
162
+ for (let i = 1; i <= m; i++) {
163
+ for (let j = 1; j <= n; j++) {
164
+ if (oldWords[i - 1] === newWords[j - 1]) {
165
+ dp[i][j] = dp[i - 1][j - 1] + 1;
166
+ } else {
167
+ dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
168
+ }
169
+ }
170
+ }
171
+
172
+ // Backtrack to produce diff operations
173
+ const ops: Array<{ type: 'keep' | 'del' | 'ins'; text: string }> = [];
174
+ let i = m;
175
+ let j = n;
176
+
177
+ while (i > 0 || j > 0) {
178
+ if (i > 0 && j > 0 && oldWords[i - 1] === newWords[j - 1]) {
179
+ ops.unshift({ type: 'keep', text: oldWords[i - 1] });
180
+ i--;
181
+ j--;
182
+ } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) {
183
+ ops.unshift({ type: 'ins', text: newWords[j - 1] });
184
+ j--;
185
+ } else {
186
+ ops.unshift({ type: 'del', text: oldWords[i - 1] });
187
+ i--;
188
+ }
189
+ }
190
+
191
+ // Merge consecutive same-type ops into spans
192
+ let html = '';
193
+ let currentType: 'keep' | 'del' | 'ins' | null = null;
194
+ let buffer: string[] = [];
195
+
196
+ const flush = () => {
197
+ if (buffer.length === 0) return;
198
+ const text = buffer.join(' ');
199
+ if (currentType === 'del') {
200
+ html += `<del class="ec-chat-diff__removed">${escapeHtml(text)}</del>`;
201
+ } else if (currentType === 'ins') {
202
+ html += `<ins class="ec-chat-diff__added">${escapeHtml(text)}</ins>`;
203
+ } else {
204
+ html += escapeHtml(text);
205
+ }
206
+ buffer = [];
207
+ };
208
+
209
+ for (const op of ops) {
210
+ if (op.type !== currentType) {
211
+ flush();
212
+ currentType = op.type;
213
+ }
214
+ buffer.push(op.text);
215
+ }
216
+ flush();
217
+
218
+ return html;
219
+ }
220
+
221
+ /**
222
+ * Tokenize text into words, preserving whitespace as separate tokens
223
+ * so the diff output is readable.
224
+ */
225
+ function tokenize(text: string): string[] {
226
+ return text.split(/(\s+)/).filter(Boolean);
227
+ }
228
+
229
+ /**
230
+ * Escape HTML special characters.
231
+ */
232
+ function escapeHtml(text: string): string {
233
+ return text
234
+ .replace(/&/g, '&amp;')
235
+ .replace(/</g, '&lt;')
236
+ .replace(/>/g, '&gt;')
237
+ .replace(/"/g, '&quot;');
238
+ }
239
+
240
+ /**
241
+ * Human-readable label for a diff type.
242
+ */
243
+ function formatDiffLabel(diffType: DiffData['diffType']): string {
244
+ switch (diffType) {
245
+ case 'edit':
246
+ return 'Content edit';
247
+ case 'replace':
248
+ return 'Content replacement';
249
+ case 'insert':
250
+ return 'New content';
251
+ }
252
+ }
@@ -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';
package/src/diff.ts ADDED
@@ -0,0 +1,159 @@
1
+ import type { ToolGroup } from './components/ToolMessage.tsx';
2
+
3
+ export type CanonicalDiffType = 'edit' | 'replace' | 'insert';
4
+ export type CanonicalDiffStatus = 'pending' | 'accepted' | 'rejected';
5
+
6
+ export interface CanonicalDiffItem {
7
+ blockIndex?: number;
8
+ originalContent?: string;
9
+ replacementContent?: string;
10
+ }
11
+
12
+ export interface CanonicalDiffEditorData {
13
+ [key: string]: unknown;
14
+ }
15
+
16
+ export interface CanonicalDiffData {
17
+ diffId: string;
18
+ diffType: CanonicalDiffType;
19
+ originalContent: string;
20
+ replacementContent: string;
21
+ status?: CanonicalDiffStatus;
22
+ summary?: string;
23
+ items?: CanonicalDiffItem[];
24
+ position?: string;
25
+ insertionPoint?: string;
26
+ editor?: CanonicalDiffEditorData;
27
+ }
28
+
29
+ type UnknownRecord = Record< string, unknown >;
30
+
31
+ function isRecord( value: unknown ): value is UnknownRecord {
32
+ return !! value && typeof value === 'object' && ! Array.isArray( value );
33
+ }
34
+
35
+ function normalizeItem( item: unknown ): CanonicalDiffItem | null {
36
+ if ( ! isRecord( item ) ) {
37
+ return null;
38
+ }
39
+
40
+ const blockIndex = typeof item.blockIndex === 'number'
41
+ ? item.blockIndex
42
+ : typeof item.block_index === 'number'
43
+ ? item.block_index
44
+ : undefined;
45
+
46
+ const originalContent = typeof item.originalContent === 'string'
47
+ ? item.originalContent
48
+ : typeof item.original_content === 'string'
49
+ ? item.original_content
50
+ : undefined;
51
+
52
+ const replacementContent = typeof item.replacementContent === 'string'
53
+ ? item.replacementContent
54
+ : typeof item.replacement_content === 'string'
55
+ ? item.replacement_content
56
+ : undefined;
57
+
58
+ if ( blockIndex === undefined && originalContent === undefined && replacementContent === undefined ) {
59
+ return null;
60
+ }
61
+
62
+ return {
63
+ blockIndex,
64
+ originalContent,
65
+ replacementContent,
66
+ };
67
+ }
68
+
69
+ export function parseCanonicalDiff( value: unknown ): CanonicalDiffData | null {
70
+ if ( ! isRecord( value ) ) {
71
+ return null;
72
+ }
73
+
74
+ const container = isRecord( value.data ) ? value.data : value;
75
+ const rawDiff = isRecord( container.diff ) ? container.diff : container;
76
+
77
+ const diffId = typeof rawDiff.diffId === 'string'
78
+ ? rawDiff.diffId
79
+ : typeof rawDiff.diff_id === 'string'
80
+ ? rawDiff.diff_id
81
+ : typeof container.diff_id === 'string'
82
+ ? container.diff_id
83
+ : '';
84
+
85
+ const diffType = rawDiff.diffType === 'replace' || rawDiff.diffType === 'insert'
86
+ ? rawDiff.diffType
87
+ : rawDiff.diffType === 'edit'
88
+ ? 'edit'
89
+ : rawDiff.diff_type === 'replace' || rawDiff.diff_type === 'insert'
90
+ ? rawDiff.diff_type
91
+ : 'edit';
92
+
93
+ const originalContent = typeof rawDiff.originalContent === 'string'
94
+ ? rawDiff.originalContent
95
+ : typeof rawDiff.original_content === 'string'
96
+ ? rawDiff.original_content
97
+ : '';
98
+
99
+ const replacementContent = typeof rawDiff.replacementContent === 'string'
100
+ ? rawDiff.replacementContent
101
+ : typeof rawDiff.replacement_content === 'string'
102
+ ? rawDiff.replacement_content
103
+ : '';
104
+
105
+ if ( ! diffId && ! originalContent && ! replacementContent ) {
106
+ return null;
107
+ }
108
+
109
+ const itemsSource = Array.isArray( rawDiff.items )
110
+ ? rawDiff.items
111
+ : Array.isArray( rawDiff.edits )
112
+ ? rawDiff.edits
113
+ : Array.isArray( rawDiff.replacements )
114
+ ? rawDiff.replacements
115
+ : undefined;
116
+
117
+ const items = itemsSource
118
+ ?.map( normalizeItem )
119
+ .filter( ( item ): item is CanonicalDiffItem => item !== null );
120
+
121
+ const summary = typeof rawDiff.summary === 'string'
122
+ ? rawDiff.summary
123
+ : typeof container.message === 'string'
124
+ ? container.message
125
+ : undefined;
126
+
127
+ const status = rawDiff.status === 'accepted' || rawDiff.status === 'rejected' || rawDiff.status === 'pending'
128
+ ? rawDiff.status
129
+ : undefined;
130
+
131
+ return {
132
+ diffId,
133
+ diffType,
134
+ originalContent,
135
+ replacementContent,
136
+ status,
137
+ summary,
138
+ items: items && items.length > 0 ? items : undefined,
139
+ position: typeof rawDiff.position === 'string' ? rawDiff.position : undefined,
140
+ insertionPoint: typeof rawDiff.insertionPoint === 'string' ? rawDiff.insertionPoint : undefined,
141
+ editor: isRecord( rawDiff.editor ) ? rawDiff.editor : undefined,
142
+ };
143
+ }
144
+
145
+ export function parseCanonicalDiffFromJson( json: string ): CanonicalDiffData | null {
146
+ try {
147
+ return parseCanonicalDiff( JSON.parse( json ) );
148
+ } catch {
149
+ return null;
150
+ }
151
+ }
152
+
153
+ export function parseCanonicalDiffFromToolGroup( group: ToolGroup ): CanonicalDiffData | null {
154
+ if ( ! group.resultMessage ) {
155
+ return null;
156
+ }
157
+
158
+ return parseCanonicalDiffFromJson( group.resultMessage.content );
159
+ }