@m6d/cortex-react 1.0.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.
- package/dist/index.d.ts +70 -0
- package/dist/index.js +3 -0
- package/dist/styles.css +2652 -0
- package/dist/theme.css +69 -0
- package/index.ts +17 -0
- package/package.json +67 -0
- package/src/chat-session.tsx +379 -0
- package/src/components/AttachmentQueue.tsx +75 -0
- package/src/components/ChatComposer.tsx +211 -0
- package/src/components/CopyButton.tsx +73 -0
- package/src/components/CortexChatWidget.tsx +538 -0
- package/src/components/JsonTree.tsx +111 -0
- package/src/components/LlmUsageBreakdown.tsx +42 -0
- package/src/components/LlmUsageChips.tsx +37 -0
- package/src/components/Message.tsx +81 -0
- package/src/components/MessageAbortedFlag.tsx +29 -0
- package/src/components/MessageAttachments.tsx +55 -0
- package/src/components/MessageList.tsx +102 -0
- package/src/components/MessageLlmInspector.tsx +184 -0
- package/src/components/MessagePart.tsx +54 -0
- package/src/components/MessageReasoningAnimated.tsx +12 -0
- package/src/components/MessageReasoningPart.tsx +76 -0
- package/src/components/MessageTextPart.tsx +78 -0
- package/src/components/MessageTokenUsage.tsx +145 -0
- package/src/components/MessageToolCallAnimated.tsx +86 -0
- package/src/components/MessageToolCallOutcome.tsx +95 -0
- package/src/components/MessageToolCallPart.tsx +95 -0
- package/src/components/MessageToolCallStatus.tsx +35 -0
- package/src/components/SubtleActivity.tsx +74 -0
- package/src/components/ThreadList.tsx +219 -0
- package/src/components/ToolAnimation.tsx +22 -0
- package/src/components/ToolExecuteCodeAnimated.tsx +8 -0
- package/src/components/ToolQueryGraphAnimated.tsx +8 -0
- package/src/config.ts +14 -0
- package/src/context.ts +48 -0
- package/src/cx.ts +4 -0
- package/src/format.ts +4 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import type { ClipboardEvent, DragEvent, KeyboardEvent } from 'react';
|
|
2
|
+
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react';
|
|
3
|
+
import { ATTACHMENT_MIME_TYPES } from '@cortex/contracts/wire';
|
|
4
|
+
import { useCortex } from '../context';
|
|
5
|
+
import { cx } from '../cx';
|
|
6
|
+
import { AttachmentQueue } from './AttachmentQueue';
|
|
7
|
+
|
|
8
|
+
const ACCEPTED_TYPES = ATTACHMENT_MIME_TYPES.join(',');
|
|
9
|
+
|
|
10
|
+
export type ChatComposerHandle = {
|
|
11
|
+
focusInput: () => void;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
// forwardRef rather than React 19's ref-as-prop: the package peers on react >=18,
|
|
15
|
+
// where a plain function component never receives `ref`.
|
|
16
|
+
// fallow-ignore-next-line complexity -- 1:1 transcription of the Angular source; restructuring would risk parity drift
|
|
17
|
+
export const ChatComposer = forwardRef<ChatComposerHandle>(function ChatComposer(_props, ref) {
|
|
18
|
+
const { t, queue, isAgentWorking, send, abort } = useCortex();
|
|
19
|
+
const [text, setText] = useState('');
|
|
20
|
+
const [dragging, setDragging] = useState(false);
|
|
21
|
+
const messageInput = useRef<HTMLTextAreaElement>(null);
|
|
22
|
+
const fileInput = useRef<HTMLInputElement>(null);
|
|
23
|
+
|
|
24
|
+
useImperativeHandle(ref, () => ({
|
|
25
|
+
focusInput: () => messageInput.current?.focus(),
|
|
26
|
+
}));
|
|
27
|
+
|
|
28
|
+
// Auto-focus after a turn, from in here rather than only via the widget's
|
|
29
|
+
// focusInput call: that call races the re-render that re-enables the
|
|
30
|
+
// textarea (focusing a still-disabled input is a no-op). This effect runs
|
|
31
|
+
// after the enabling render has committed.
|
|
32
|
+
const wasWorking = useRef(isAgentWorking);
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
if (wasWorking.current && !isAgentWorking) messageInput.current?.focus();
|
|
35
|
+
wasWorking.current = isAgentWorking;
|
|
36
|
+
}, [isAgentWorking]);
|
|
37
|
+
|
|
38
|
+
const canSend = !queue.busy && (Boolean(text.trim()) || queue.hasReady);
|
|
39
|
+
|
|
40
|
+
function onSend() {
|
|
41
|
+
if (isAgentWorking || !canSend) {
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const captionKey = 'translate_attachment_caption';
|
|
46
|
+
const translated = t(captionKey);
|
|
47
|
+
const caption =
|
|
48
|
+
translated !== captionKey ? translated : 'Please take a look at the attached files.';
|
|
49
|
+
const prompt = text.trim() || caption;
|
|
50
|
+
const attachments = queue.consumeReady();
|
|
51
|
+
|
|
52
|
+
setText('');
|
|
53
|
+
void send(
|
|
54
|
+
prompt,
|
|
55
|
+
attachments.map((attachment) => attachment.summary),
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function onKeydown(event: KeyboardEvent<HTMLTextAreaElement>) {
|
|
60
|
+
if (event.key === 'Enter' && !event.shiftKey) {
|
|
61
|
+
event.preventDefault();
|
|
62
|
+
onSend();
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function onDragOver(event: DragEvent<HTMLDivElement>) {
|
|
67
|
+
event.preventDefault();
|
|
68
|
+
setDragging(true);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function onDragLeave(event: DragEvent<HTMLDivElement>) {
|
|
72
|
+
// Moving onto a child bubbles a dragleave up to the composer; only a pointer that
|
|
73
|
+
// has actually left it should drop the highlight.
|
|
74
|
+
const area = event.currentTarget;
|
|
75
|
+
if (!area.contains(event.relatedTarget as Node | null)) {
|
|
76
|
+
setDragging(false);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function onDrop(event: DragEvent<HTMLDivElement>) {
|
|
81
|
+
event.preventDefault();
|
|
82
|
+
setDragging(false);
|
|
83
|
+
queue.accept([...event.dataTransfer.files]);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function onPaste(event: ClipboardEvent<HTMLTextAreaElement>) {
|
|
87
|
+
const files = event.clipboardData.files;
|
|
88
|
+
if (!files.length) return;
|
|
89
|
+
|
|
90
|
+
event.preventDefault();
|
|
91
|
+
queue.accept([...files]);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<div
|
|
96
|
+
className="cortex-widget__input-area"
|
|
97
|
+
onDragOver={onDragOver}
|
|
98
|
+
onDragLeave={onDragLeave}
|
|
99
|
+
onDrop={onDrop}
|
|
100
|
+
>
|
|
101
|
+
<AttachmentQueue />
|
|
102
|
+
|
|
103
|
+
<div
|
|
104
|
+
className={cx(
|
|
105
|
+
'cortex-widget__input-box',
|
|
106
|
+
isAgentWorking && 'cortex-widget__input-box--disabled',
|
|
107
|
+
!isAgentWorking && 'cortex-widget__input-box--enabled',
|
|
108
|
+
dragging && 'cortex-widget__input-box--dragging',
|
|
109
|
+
)}
|
|
110
|
+
>
|
|
111
|
+
<textarea
|
|
112
|
+
ref={messageInput}
|
|
113
|
+
onKeyDown={onKeydown}
|
|
114
|
+
onPaste={onPaste}
|
|
115
|
+
value={text}
|
|
116
|
+
onChange={(event) => setText(event.target.value)}
|
|
117
|
+
placeholder={
|
|
118
|
+
dragging
|
|
119
|
+
? t('translate_drop_files_here')
|
|
120
|
+
: isAgentWorking
|
|
121
|
+
? ''
|
|
122
|
+
: t('translate_type_a_message')
|
|
123
|
+
}
|
|
124
|
+
disabled={isAgentWorking}
|
|
125
|
+
rows={1}
|
|
126
|
+
className={cx(
|
|
127
|
+
'cortex-widget__textarea',
|
|
128
|
+
isAgentWorking && 'cortex-widget__textarea--disabled',
|
|
129
|
+
)}
|
|
130
|
+
/>
|
|
131
|
+
{isAgentWorking ? (
|
|
132
|
+
/* Stop button */
|
|
133
|
+
<button onClick={() => void abort()} className="cortex-stop-btn">
|
|
134
|
+
{/* Pulsing ring */}
|
|
135
|
+
<span className="cortex-stop-btn__ring"></span>
|
|
136
|
+
{/* Stop icon (rounded square) */}
|
|
137
|
+
<svg
|
|
138
|
+
width="12"
|
|
139
|
+
height="12"
|
|
140
|
+
viewBox="0 0 12 12"
|
|
141
|
+
fill="none"
|
|
142
|
+
className="cortex-stop-btn__icon"
|
|
143
|
+
>
|
|
144
|
+
<rect x="1" y="1" width="10" height="10" rx="2.5" fill="currentColor" />
|
|
145
|
+
</svg>
|
|
146
|
+
</button>
|
|
147
|
+
) : (
|
|
148
|
+
<div className="cortex-widget__input-actions">
|
|
149
|
+
{/* Attach button */}
|
|
150
|
+
<button
|
|
151
|
+
type="button"
|
|
152
|
+
onClick={() => fileInput.current?.click()}
|
|
153
|
+
className="cortex-widget__attach-btn"
|
|
154
|
+
aria-label={t('translate_attach_files')}
|
|
155
|
+
>
|
|
156
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
|
|
157
|
+
<path
|
|
158
|
+
d="M10.5 5.5 6.2 9.8a1.4 1.4 0 0 0 2 2l4.6-4.6a2.8 2.8 0 0 0-4-4L4.2 7.8a4.2 4.2 0 0 0 6 6l4-4"
|
|
159
|
+
stroke="currentColor"
|
|
160
|
+
strokeWidth="1.3"
|
|
161
|
+
strokeLinecap="round"
|
|
162
|
+
strokeLinejoin="round"
|
|
163
|
+
/>
|
|
164
|
+
</svg>
|
|
165
|
+
</button>
|
|
166
|
+
<input
|
|
167
|
+
ref={fileInput}
|
|
168
|
+
type="file"
|
|
169
|
+
multiple
|
|
170
|
+
accept={ACCEPTED_TYPES}
|
|
171
|
+
onChange={(event) => {
|
|
172
|
+
queue.accept([...(event.target.files ?? [])]);
|
|
173
|
+
|
|
174
|
+
// Cleared so picking the same file again still fires `change`.
|
|
175
|
+
event.target.value = '';
|
|
176
|
+
}}
|
|
177
|
+
className="cortex-widget__file-input"
|
|
178
|
+
/>
|
|
179
|
+
|
|
180
|
+
{/* Send button */}
|
|
181
|
+
<button
|
|
182
|
+
onClick={onSend}
|
|
183
|
+
className={cx(
|
|
184
|
+
'cortex-widget__send-btn',
|
|
185
|
+
!canSend && 'cortex-widget__send-btn--empty',
|
|
186
|
+
canSend && 'cortex-widget__send-btn--ready',
|
|
187
|
+
)}
|
|
188
|
+
disabled={!canSend}
|
|
189
|
+
>
|
|
190
|
+
<svg
|
|
191
|
+
width="14"
|
|
192
|
+
height="14"
|
|
193
|
+
viewBox="0 0 16 16"
|
|
194
|
+
fill="none"
|
|
195
|
+
className="cortex-widget__send-icon"
|
|
196
|
+
>
|
|
197
|
+
<path
|
|
198
|
+
d="M3 8h10M9 4l4 4-4 4"
|
|
199
|
+
stroke="currentColor"
|
|
200
|
+
strokeWidth="1.5"
|
|
201
|
+
strokeLinecap="round"
|
|
202
|
+
strokeLinejoin="round"
|
|
203
|
+
/>
|
|
204
|
+
</svg>
|
|
205
|
+
</button>
|
|
206
|
+
</div>
|
|
207
|
+
)}
|
|
208
|
+
</div>
|
|
209
|
+
</div>
|
|
210
|
+
);
|
|
211
|
+
});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { useRef, useState } from 'react';
|
|
2
|
+
import type { MouseEvent } from 'react';
|
|
3
|
+
import { cx } from '../cx';
|
|
4
|
+
|
|
5
|
+
type CopyButtonProps = {
|
|
6
|
+
value: string;
|
|
7
|
+
className?: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
export function CopyButton({ value, className }: CopyButtonProps) {
|
|
11
|
+
const [copied, setCopied] = useState(false);
|
|
12
|
+
const resetTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
13
|
+
|
|
14
|
+
async function copy(event: MouseEvent<HTMLButtonElement>) {
|
|
15
|
+
event.stopPropagation();
|
|
16
|
+
event.preventDefault();
|
|
17
|
+
|
|
18
|
+
try {
|
|
19
|
+
await navigator.clipboard.writeText(value);
|
|
20
|
+
setCopied(true);
|
|
21
|
+
|
|
22
|
+
if (resetTimer.current) clearTimeout(resetTimer.current);
|
|
23
|
+
resetTimer.current = setTimeout(() => setCopied(false), 1500);
|
|
24
|
+
} catch {
|
|
25
|
+
// Clipboard API not available — silent fail
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<span className={cx('cortex-copy-btn', className)}>
|
|
31
|
+
<button
|
|
32
|
+
className={cx('cortex-copy-btn__button', copied && 'cortex-copy-btn__button--copied')}
|
|
33
|
+
onClick={(event) => void copy(event)}
|
|
34
|
+
aria-label={copied ? 'Copied' : 'Copy to clipboard'}
|
|
35
|
+
type="button"
|
|
36
|
+
>
|
|
37
|
+
{copied ? (
|
|
38
|
+
<svg
|
|
39
|
+
className="cortex-copy-btn__icon cortex-copy-btn__icon--check"
|
|
40
|
+
width="13"
|
|
41
|
+
height="13"
|
|
42
|
+
viewBox="0 0 24 24"
|
|
43
|
+
fill="none"
|
|
44
|
+
>
|
|
45
|
+
<path
|
|
46
|
+
d="M5 13l4 4L19 7"
|
|
47
|
+
stroke="currentColor"
|
|
48
|
+
strokeWidth="2.5"
|
|
49
|
+
strokeLinecap="round"
|
|
50
|
+
strokeLinejoin="round"
|
|
51
|
+
/>
|
|
52
|
+
</svg>
|
|
53
|
+
) : (
|
|
54
|
+
<svg
|
|
55
|
+
className="cortex-copy-btn__icon"
|
|
56
|
+
width="13"
|
|
57
|
+
height="13"
|
|
58
|
+
viewBox="0 0 24 24"
|
|
59
|
+
fill="none"
|
|
60
|
+
>
|
|
61
|
+
<rect x="9" y="9" width="12" height="12" rx="2" stroke="currentColor" strokeWidth="2" />
|
|
62
|
+
<path
|
|
63
|
+
d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"
|
|
64
|
+
stroke="currentColor"
|
|
65
|
+
strokeWidth="2"
|
|
66
|
+
strokeLinecap="round"
|
|
67
|
+
/>
|
|
68
|
+
</svg>
|
|
69
|
+
)}
|
|
70
|
+
</button>
|
|
71
|
+
</span>
|
|
72
|
+
);
|
|
73
|
+
}
|