@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.
Files changed (37) hide show
  1. package/dist/index.d.ts +70 -0
  2. package/dist/index.js +3 -0
  3. package/dist/styles.css +2652 -0
  4. package/dist/theme.css +69 -0
  5. package/index.ts +17 -0
  6. package/package.json +67 -0
  7. package/src/chat-session.tsx +379 -0
  8. package/src/components/AttachmentQueue.tsx +75 -0
  9. package/src/components/ChatComposer.tsx +211 -0
  10. package/src/components/CopyButton.tsx +73 -0
  11. package/src/components/CortexChatWidget.tsx +538 -0
  12. package/src/components/JsonTree.tsx +111 -0
  13. package/src/components/LlmUsageBreakdown.tsx +42 -0
  14. package/src/components/LlmUsageChips.tsx +37 -0
  15. package/src/components/Message.tsx +81 -0
  16. package/src/components/MessageAbortedFlag.tsx +29 -0
  17. package/src/components/MessageAttachments.tsx +55 -0
  18. package/src/components/MessageList.tsx +102 -0
  19. package/src/components/MessageLlmInspector.tsx +184 -0
  20. package/src/components/MessagePart.tsx +54 -0
  21. package/src/components/MessageReasoningAnimated.tsx +12 -0
  22. package/src/components/MessageReasoningPart.tsx +76 -0
  23. package/src/components/MessageTextPart.tsx +78 -0
  24. package/src/components/MessageTokenUsage.tsx +145 -0
  25. package/src/components/MessageToolCallAnimated.tsx +86 -0
  26. package/src/components/MessageToolCallOutcome.tsx +95 -0
  27. package/src/components/MessageToolCallPart.tsx +95 -0
  28. package/src/components/MessageToolCallStatus.tsx +35 -0
  29. package/src/components/SubtleActivity.tsx +74 -0
  30. package/src/components/ThreadList.tsx +219 -0
  31. package/src/components/ToolAnimation.tsx +22 -0
  32. package/src/components/ToolExecuteCodeAnimated.tsx +8 -0
  33. package/src/components/ToolQueryGraphAnimated.tsx +8 -0
  34. package/src/config.ts +14 -0
  35. package/src/context.ts +48 -0
  36. package/src/cx.ts +4 -0
  37. package/src/format.ts +4 -0
package/dist/theme.css ADDED
@@ -0,0 +1,69 @@
1
+ /* fallow-ignore-file code-duplication -- deliberately restates styles/_theme.scss: the whole point of this file is the full token set in one place */
2
+ /*
3
+ * Theming surface.
4
+ *
5
+ * Every value below is the widget's own default, restated here so the full set is
6
+ * discoverable in one place — the same declarations already ship inside cortex.css,
7
+ * so importing this file changes nothing until you edit it. Override any token on
8
+ * :root, or on any ancestor of the widget, to retheme it.
9
+ *
10
+ * Each color is a light-dark() pair. Which side applies follows the widget root's
11
+ * color-scheme, set by the `theme` config option ('light' | 'dark' | 'system').
12
+ * The radius and font tokens below the colors are plain values — neither depends
13
+ * on the scheme.
14
+ */
15
+ :root {
16
+ --cortex-color-surface: light-dark(#ffffff, #0a0a0a);
17
+ --cortex-color-surface-subtle: light-dark(#f8fafc, #141414);
18
+ --cortex-color-surface-muted: light-dark(#f1f5f9, #1f1f1f);
19
+
20
+ --cortex-color-border: light-dark(#e2e8f0, #2e2e2e);
21
+ --cortex-color-border-strong: light-dark(#cbd5e1, #444444);
22
+
23
+ --cortex-color-text-heading: light-dark(#1e293b, #fafafa);
24
+ --cortex-color-text-strong: light-dark(#334155, #e5e5e5);
25
+ --cortex-color-text: light-dark(#475569, #d4d4d4);
26
+ --cortex-color-text-secondary: light-dark(#64748b, #a3a3a3);
27
+ --cortex-color-text-muted: light-dark(#94a3b8, #737373);
28
+
29
+ --cortex-color-primary: light-dark(#1e293b, #fafafa);
30
+ --cortex-color-primary-foreground: light-dark(#ffffff, #0a0a0a);
31
+ --cortex-color-accent: light-dark(#4f46e5, #818cf8);
32
+ --cortex-color-accent-surface: light-dark(#e0e7ff, #312e81);
33
+ --cortex-color-link: light-dark(#2563eb, #60a5fa);
34
+ --cortex-color-hover: light-dark(rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.06));
35
+
36
+ /* User messages default to the same subtle surface every other quiet panel
37
+ * uses, split out so a consumer can give their own side of the chat a color
38
+ * without touching anything else. */
39
+ --cortex-color-user-surface: var(--cortex-color-surface-subtle);
40
+ --cortex-color-user-border: var(--cortex-color-border);
41
+
42
+ --cortex-color-success: light-dark(#059669, #34d399);
43
+ --cortex-color-success-surface: light-dark(#ecfdf5, #064e3b);
44
+ --cortex-color-success-border: light-dark(#d1fae5, #065f46);
45
+ --cortex-color-danger: light-dark(#dc2626, #f87171);
46
+ --cortex-color-danger-surface: light-dark(#fef2f2, #450a0a);
47
+ --cortex-color-danger-border: light-dark(#fecaca, #7f1d1d);
48
+
49
+ /* The corner scale every rounded surface derives from: the widget multiplies
50
+ * this by 0.5/0.75/1.5/2, so overriding it rescales all of them together. */
51
+ --cortex-radius: 0.5rem;
52
+
53
+ --cortex-font-mono: var(
54
+ --default-mono-font-family,
55
+ ui-monospace,
56
+ SFMono-Regular,
57
+ Menlo,
58
+ Monaco,
59
+ Consolas,
60
+ monospace
61
+ );
62
+ --cortex-font-size: 0.8125rem;
63
+
64
+ /* The widget's headings read --cortex-font-display and fall back to `inherit`,
65
+ * so it ships unset on purpose — declaring it here is how you opt in:
66
+ *
67
+ * --cortex-font-display: 'Your Display Face', sans-serif;
68
+ */
69
+ }
package/index.ts ADDED
@@ -0,0 +1,17 @@
1
+ /*
2
+ * Public API Surface of cortex-react — mirrors cortex-angular's public-api.ts.
3
+ */
4
+
5
+ // Config
6
+ export type { CortexReactConfig, CortexToolComponentProps } from './src/config';
7
+ export type {
8
+ CortexHooks,
9
+ CortexTheme,
10
+ CortexToolCall,
11
+ CortexToolCallContext,
12
+ CortexTransport,
13
+ CortexViewMode,
14
+ } from '@cortex/client';
15
+
16
+ // Components
17
+ export { CortexChatWidget } from './src/components/CortexChatWidget';
package/package.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "@m6d/cortex-react",
3
+ "version": "1.0.0",
4
+ "description": "React chat widget SDK for cortex servers",
5
+ "license": "MIT",
6
+ "sideEffects": [
7
+ "dist/*.css"
8
+ ],
9
+ "type": "module",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "bun": "./index.ts",
14
+ "import": "./dist/index.js",
15
+ "default": "./dist/index.js"
16
+ },
17
+ "./styles.css": "./dist/styles.css",
18
+ "./theme.css": "./dist/theme.css"
19
+ },
20
+ "main": "./dist/index.js",
21
+ "types": "./dist/index.d.ts",
22
+ "files": [
23
+ "dist",
24
+ "src",
25
+ "!src/**/*.test.ts",
26
+ "!src/**/*.test.tsx",
27
+ "theme.css",
28
+ "index.ts",
29
+ "README.md"
30
+ ],
31
+ "scripts": {
32
+ "build": "bash scripts/build.sh",
33
+ "check": "tsc --noEmit"
34
+ },
35
+ "devDependencies": {
36
+ "@cortex/client": "workspace:*",
37
+ "@cortex/contracts": "workspace:*",
38
+ "@m6d/prettier-config": "workspace:*",
39
+ "@m6d/tsconfig": "workspace:*",
40
+ "@tanstack/ai": "^0.43.1",
41
+ "@tanstack/ai-client": "^0.23.1",
42
+ "@tanstack/ai-react": "^0.19.1",
43
+ "@types/bun": "latest",
44
+ "@types/react": "^19",
45
+ "@types/react-dom": "^19",
46
+ "dompurify": "^3.3.3",
47
+ "dts-bundle-generator": "^9.5.1",
48
+ "highlight.js": "^11.11.1",
49
+ "marked": "^17.0.4",
50
+ "react": "^19.2.0",
51
+ "sass": "^1.83.0",
52
+ "typescript": "~5.9.3"
53
+ },
54
+ "peerDependencies": {
55
+ "@tanstack/ai": "^0.43.1",
56
+ "@tanstack/ai-client": "^0.23.1",
57
+ "@tanstack/ai-react": "^0.19.1",
58
+ "dompurify": "^3.0.0",
59
+ "highlight.js": "^11.0.0",
60
+ "marked": "^17.0.0",
61
+ "react": ">=18",
62
+ "react-dom": ">=18"
63
+ },
64
+ "publishConfig": {
65
+ "access": "public"
66
+ }
67
+ }
@@ -0,0 +1,379 @@
1
+ import { useEffect, useLayoutEffect, useMemo, useRef } from 'react';
2
+ import type { ToolCallPart, UIMessage } from '@tanstack/ai';
3
+ import { useChat } from '@tanstack/ai-react';
4
+ import {
5
+ createCortexConnection,
6
+ metadataByMessageId,
7
+ settleTurn,
8
+ unansweredToolCalls,
9
+ type CortexApiClient,
10
+ type MountMode,
11
+ } from '@cortex/client';
12
+ import type { AttachmentSummary, MessageMetadata, ThreadSummary } from '@cortex/contracts/wire';
13
+ import type { CortexHooks } from '@cortex/client';
14
+ import type { CortexReactConfig } from './config';
15
+
16
+ export type SessionUi = {
17
+ messages: UIMessage[];
18
+ isAgentWorking: boolean;
19
+ isLoadingMessages: boolean;
20
+ hasPendingToolCalls: boolean;
21
+ /**
22
+ * The per-message facts the SDK's `UIMessage` has no room for — usage, model
23
+ * id, the aborted flag, attachments. They are cortex's own envelope around the
24
+ * stored parts, so they arrive with the persisted transcript rather than over
25
+ * the stream, and are merged back in by message id at render time.
26
+ */
27
+ messageMetadata: ReadonlyMap<string, MessageMetadata>;
28
+ };
29
+
30
+ export type SessionPatch = Partial<SessionUi> | ((previous: SessionUi) => Partial<SessionUi>);
31
+
32
+ export type PendingSend = { prompt: string; attachments: AttachmentSummary[] };
33
+
34
+ /** The imperative face the widget keeps of the mounted session. */
35
+ export type SessionHandle = {
36
+ threadId: string;
37
+ send: (prompt: string, attachments: AttachmentSummary[]) => Promise<void>;
38
+ abort: () => Promise<void>;
39
+ addToolResult: (toolCallId: string, toolName: string, output: unknown) => void;
40
+ reattach: (thread: ThreadSummary) => void;
41
+ refreshMessages: (threadId: string) => void;
42
+ };
43
+
44
+ type ChatSessionProps = {
45
+ thread: ThreadSummary;
46
+ mode: MountMode;
47
+ api: CortexApiClient;
48
+ configRef: { current: CortexReactConfig };
49
+ sessionRef: { current: SessionHandle | undefined };
50
+ /** Sends that arrived while no session was mounted; consumed in order on mount. */
51
+ pendingSendRef: { current: PendingSend[] };
52
+ patchUi: (patch: SessionPatch) => void;
53
+ setRunning: (threadId: string, isRunning: boolean) => void;
54
+ /** Remounts this session in `reload` mode so hydration re-reads the transcript
55
+ * and re-attaches to whatever run is live now. */
56
+ remount: (thread: ThreadSummary) => void;
57
+ onTurnFinished: () => void;
58
+ onSendFailed: () => void;
59
+ };
60
+
61
+ /**
62
+ * The headless per-thread chat driver — CortexChatService's turn lifecycle
63
+ * around the SDK's `useChat`. It renders nothing: its state is lifted into the
64
+ * widget's context so the composer (which must keep its draft across thread
65
+ * switches) and the message list live outside the remount boundary. The
66
+ * `key={threadId}:{epoch}` the widget renders it under IS the per-thread
67
+ * lifetime — remounting replaces Angular's environment-injector dance.
68
+ */
69
+ // fallow-ignore-next-line complexity -- 1:1 transcription of the Angular source; restructuring would risk parity drift
70
+ export function ChatSession(props: ChatSessionProps) {
71
+ const { thread, api, patchUi } = props;
72
+
73
+ const alive = useRef(true);
74
+ /**
75
+ * Whether this widget is driving the run it is showing or watching one another
76
+ * tab started. A watcher owes nothing: the tool calls it sees belong to the tab
77
+ * that sent the message, and how the turn ended is the server's to tell it.
78
+ */
79
+ const ownsRun = useRef(false);
80
+ /**
81
+ * Whether the server has answered the turn in flight. Until it does, a failure
82
+ * means the message never got out and the composer takes back the files it was
83
+ * carrying; after it, those files are on the message the server stored.
84
+ */
85
+ const serverAnswered = useRef(false);
86
+ const wasAborted = useRef(false);
87
+ const wasWorking = useRef(false);
88
+ const dispatchedToolCalls = useRef(new Set<string>());
89
+ const refreshing = useRef(Promise.resolve());
90
+
91
+ // Created once per mount: `useChat` captures the connection at client
92
+ // creation, and a new mount is exactly what a re-hydration needs.
93
+ const connection = useMemo(
94
+ () =>
95
+ createCortexConnection({
96
+ api,
97
+ thread: { id: thread.id, isRunning: thread.isRunning },
98
+ mode: props.mode,
99
+ onHydrated: (rows, messages) => {
100
+ if (!alive.current) return;
101
+
102
+ patchUi({
103
+ messageMetadata: metadataByMessageId(rows),
104
+ hasPendingToolCalls: unansweredToolCalls(messages).length > 0,
105
+ });
106
+ },
107
+ setLoadingMessages: (loading) => {
108
+ if (alive.current) patchUi({ isLoadingMessages: loading });
109
+ },
110
+ }),
111
+ // eslint-disable-next-line react-hooks/exhaustive-deps
112
+ [],
113
+ );
114
+
115
+ const chat = useChat({
116
+ threadId: thread.id,
117
+ persistence: true,
118
+ connection,
119
+ // Both guarded: the SDK disposes a replaced client a macrotask after
120
+ // unmount, so a dead session's callbacks can still fire into the new one.
121
+ onChunk: () => {
122
+ if (!alive.current) return;
123
+ serverAnswered.current = true;
124
+ },
125
+ onError: () => {
126
+ if (!alive.current) return;
127
+ patchUi({ hasPendingToolCalls: false });
128
+ // A failure only means the message never went while the server has said
129
+ // nothing at all. Once it has answered it holds the message, and the
130
+ // files it claimed are on it — reporting that as a failed send would
131
+ // offer the composer files it can no longer send.
132
+ if (serverAnswered.current) props.onTurnFinished();
133
+ else props.onSendFailed();
134
+ },
135
+ });
136
+
137
+ const chatRef = useRef(chat);
138
+ chatRef.current = chat;
139
+ const isLoadingRef = useRef(chat.isLoading);
140
+ isLoadingRef.current = chat.isLoading;
141
+
142
+ function syncPendingToolCalls() {
143
+ const pending = unansweredToolCalls(chatRef.current.messages);
144
+ patchUi({ hasPendingToolCalls: pending.length > 0 });
145
+ return pending;
146
+ }
147
+
148
+ async function resolveToolCall(
149
+ call: ToolCallPart,
150
+ onToolCall: NonNullable<CortexHooks['onToolCall']>,
151
+ ) {
152
+ const result = onToolCall(
153
+ { toolCallId: call.id, toolName: call.name, input: call.input },
154
+ { threadId: thread.id },
155
+ );
156
+ if (result === null || result === undefined) return;
157
+
158
+ const output = await Promise.resolve(result);
159
+ if (output === null || output === undefined) return;
160
+ // The hook is free to take its time, and the thread may have moved on since.
161
+ if (!alive.current) return;
162
+
163
+ await chatRef.current.addToolResult({ toolCallId: call.id, tool: call.name, output });
164
+ syncPendingToolCalls();
165
+ }
166
+
167
+ /**
168
+ * Hands the calls the run left open to the consumer's hook — cortex's tools all
169
+ * run on the server, so anything still unanswered when the stream ends is one
170
+ * the client was asked to resolve. Reports whether any are still waiting.
171
+ */
172
+ function dispatchPendingToolCalls() {
173
+ const pending = syncPendingToolCalls();
174
+ const onToolCall = props.configRef.current.hooks?.onToolCall;
175
+ if (!onToolCall) return pending.length > 0;
176
+
177
+ for (const call of pending) {
178
+ // Anything short of complete arguments is a call the model never finished
179
+ // asking for — `input` is not parsed until then, so there is nothing to
180
+ // hand over. It still counts as pending; it is just not answerable.
181
+ if (call.state !== 'input-complete') continue;
182
+ if (dispatchedToolCalls.current.has(call.id)) continue;
183
+
184
+ dispatchedToolCalls.current.add(call.id);
185
+ void resolveToolCall(call, onToolCall);
186
+ }
187
+
188
+ return pending.length > 0;
189
+ }
190
+
191
+ async function readBackMessages(threadId: string) {
192
+ if (!alive.current || threadId !== thread.id) return;
193
+
194
+ await settleTurn({
195
+ api,
196
+ threadId,
197
+ isStale: () => !alive.current,
198
+ isStreaming: () => isLoadingRef.current,
199
+ absorbMetadata: (metadata) => patchUi({ messageMetadata: metadata }),
200
+ setMessages: (messages) => {
201
+ chatRef.current.setMessages(messages);
202
+ patchUi({ hasPendingToolCalls: unansweredToolCalls(messages).length > 0 });
203
+ },
204
+ });
205
+ }
206
+
207
+ function refreshMessages(threadId: string) {
208
+ // Queued rather than deduplicated: a refresh asked for while one is in
209
+ // flight is asking about a newer state — the commit that has just landed —
210
+ // so it has to follow that one rather than be dropped in favour of it.
211
+ refreshing.current = refreshing.current.then(() => readBackMessages(threadId));
212
+ return refreshing.current;
213
+ }
214
+
215
+ /**
216
+ * A finished stream leaves the SDK holding only what came over the wire, while
217
+ * usage, model id and attachments live in cortex's envelope — so a settled turn
218
+ * is read back from the database. The one turn that is not read back is one
219
+ * this widget is still in the middle of: a tool call it owes an answer to.
220
+ */
221
+ async function settleSession() {
222
+ // Cancelling is the one exit that does not wait for the server: the turn
223
+ // settles on the thread:messages-updated event the commit sends, not here.
224
+ if (wasAborted.current) {
225
+ wasAborted.current = false;
226
+ return;
227
+ }
228
+
229
+ if (ownsRun.current && dispatchPendingToolCalls()) return;
230
+
231
+ await refreshMessages(thread.id);
232
+ }
233
+
234
+ async function performSend(prompt: string, attachments: AttachmentSummary[]) {
235
+ props.setRunning(thread.id, true);
236
+ ownsRun.current = true;
237
+ serverAnswered.current = false;
238
+
239
+ const message = {
240
+ id: crypto.randomUUID(),
241
+ role: 'user',
242
+ parts: [{ type: 'text', content: prompt }],
243
+ } satisfies UIMessage;
244
+
245
+ // The server claims the thread's pending attachments for this message and
246
+ // serves them back on it; recording them now only saves the round trip.
247
+ if (attachments.length) {
248
+ patchUi((previous) => ({
249
+ messageMetadata: new Map(previous.messageMetadata).set(message.id, { attachments }),
250
+ }));
251
+ }
252
+
253
+ await chatRef.current.append(message);
254
+ }
255
+
256
+ async function abort() {
257
+ if (!isLoadingRef.current) return;
258
+
259
+ // Ask the server first: the stop button can outlive the run, and an abort
260
+ // that found nothing to abort must not be presented as one. Only a confirmed
261
+ // abort suppresses the settle-time read-back — the unconfirmed path falls
262
+ // through to it and pulls the truthful committed turn.
263
+ const { aborted } = await api.abortStream(thread.id).catch(() => ({ aborted: false }));
264
+ // The selection can move while the request is out; a late answer about
265
+ // another thread must not poison the one selected now.
266
+ if (!alive.current) return;
267
+
268
+ if (aborted) {
269
+ wasAborted.current = true;
270
+ props.setRunning(thread.id, false);
271
+ }
272
+
273
+ chatRef.current.stop();
274
+ patchUi({ hasPendingToolCalls: false });
275
+ }
276
+
277
+ /** Answers a tool call on behalf of a consumer-supplied tool component. */
278
+ function addToolResult(toolCallId: string, toolName: string, output: unknown) {
279
+ void chatRef.current
280
+ .addToolResult({ toolCallId, tool: toolName, output })
281
+ .then(() => syncPendingToolCalls());
282
+ }
283
+
284
+ function reattach(freshThread: ThreadSummary) {
285
+ if (isLoadingRef.current) return;
286
+
287
+ // Only a live run needs a new client: joining one is something only a mount
288
+ // can do. With nothing to join, re-reading the transcript in place leaves
289
+ // the conversation on screen instead of tearing it down for a round trip.
290
+ if (freshThread.isRunning) {
291
+ props.remount(freshThread);
292
+ return;
293
+ }
294
+
295
+ void refreshMessages(freshThread.id);
296
+ }
297
+
298
+ useEffect(() => {
299
+ alive.current = true;
300
+ return () => {
301
+ alive.current = false;
302
+ };
303
+ }, []);
304
+
305
+ // The widget's imperative face. One stable object per mount whose methods are
306
+ // refreshed every render, registered in a layout effect: on a key remount the
307
+ // replaced instance's cleanup runs before the new instance registers, and the
308
+ // identity check keeps it from wiping a registration that is not its own.
309
+ const handleRef = useRef<SessionHandle | undefined>(undefined);
310
+ handleRef.current = {
311
+ threadId: thread.id,
312
+ send: performSend,
313
+ abort,
314
+ addToolResult,
315
+ reattach,
316
+ refreshMessages: (threadId) => {
317
+ void refreshMessages(threadId);
318
+ },
319
+ };
320
+ useLayoutEffect(() => {
321
+ props.sessionRef.current = handleRef.current;
322
+ });
323
+ useLayoutEffect(() => {
324
+ return () => {
325
+ if (props.sessionRef.current === handleRef.current) {
326
+ props.sessionRef.current = undefined;
327
+ }
328
+ };
329
+ // eslint-disable-next-line react-hooks/exhaustive-deps
330
+ }, []);
331
+
332
+ // Sends that were waiting for a thread to exist go out with this mount.
333
+ // Deferred a tick and cancelled on cleanup: under StrictMode the first,
334
+ // throwaway mount's client is disposed right after the effect runs, so a
335
+ // synchronous drain would tie the send to a client whose stream is already
336
+ // aborted. Leaving the queue untouched until the timer fires also hands it
337
+ // to the next mount if this one is torn down first.
338
+ useEffect(() => {
339
+ if (!props.pendingSendRef.current.length) return;
340
+
341
+ const timer = setTimeout(() => {
342
+ const pending = props.pendingSendRef.current;
343
+ props.pendingSendRef.current = [];
344
+ void (async () => {
345
+ for (const { prompt, attachments } of pending) {
346
+ await performSend(prompt, attachments);
347
+ }
348
+ })();
349
+ });
350
+ return () => clearTimeout(timer);
351
+ // eslint-disable-next-line react-hooks/exhaustive-deps
352
+ }, []);
353
+
354
+ useEffect(() => {
355
+ patchUi({ messages: chat.messages });
356
+ // eslint-disable-next-line react-hooks/exhaustive-deps
357
+ }, [chat.messages]);
358
+
359
+ // The end of a turn is the client falling idle, not the SDK's onFinish: the
360
+ // agent loop ends a run per iteration, so onFinish fires while the turn is
361
+ // still going. Both the tool hand-off and the refetch wait for the whole
362
+ // exchange.
363
+ useEffect(() => {
364
+ patchUi({ isAgentWorking: chat.isLoading });
365
+
366
+ if (chat.isLoading) {
367
+ wasWorking.current = true;
368
+ return;
369
+ }
370
+ if (!wasWorking.current) return;
371
+
372
+ wasWorking.current = false;
373
+ props.onTurnFinished();
374
+ void settleSession();
375
+ // eslint-disable-next-line react-hooks/exhaustive-deps
376
+ }, [chat.isLoading]);
377
+
378
+ return null;
379
+ }
@@ -0,0 +1,75 @@
1
+ import { useCortex } from '../context';
2
+ import { cx } from '../cx';
3
+
4
+ export function AttachmentQueue() {
5
+ const { queue, t } = useCortex();
6
+
7
+ if (!queue.items.length) return null;
8
+
9
+ return (
10
+ <div className="cortex-attachment-queue">
11
+ {queue.items.map((item) => (
12
+ <div
13
+ key={item.localId}
14
+ className={cx(
15
+ 'cortex-attachment-chip',
16
+ item.status === 'error' && 'cortex-attachment-chip--error',
17
+ )}
18
+ title={item.status === 'error' ? t('translate_attachment_rejected') : ''}
19
+ >
20
+ {item.status === 'uploading' || item.status === 'deleting' ? (
21
+ <svg className="cortex-attachment-chip__spinner" viewBox="0 0 16 16" fill="none">
22
+ <circle
23
+ cx="8"
24
+ cy="8"
25
+ r="6"
26
+ stroke="currentColor"
27
+ strokeWidth="2"
28
+ className="cortex-attachment-chip__spinner-track"
29
+ />
30
+ <path
31
+ d="M14 8a6 6 0 0 0-6-6"
32
+ stroke="currentColor"
33
+ strokeWidth="2"
34
+ strokeLinecap="round"
35
+ />
36
+ </svg>
37
+ ) : (
38
+ <svg viewBox="0 0 16 16" className="cortex-attachment-chip__icon" fill="none">
39
+ <path
40
+ d="M4 1.5h5.172a2 2 0 0 1 1.414.586l2.328 2.328a2 2 0 0 1 .586 1.414V12.5a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2v-9a2 2 0 0 1 2-2Z"
41
+ stroke="currentColor"
42
+ strokeWidth="1.25"
43
+ />
44
+ <path
45
+ d="M9.5 1.5v2a2 2 0 0 0 2 2h2"
46
+ stroke="currentColor"
47
+ strokeWidth="1.25"
48
+ strokeLinecap="round"
49
+ />
50
+ </svg>
51
+ )}
52
+
53
+ <span className="cortex-attachment-chip__name">{item.filename}</span>
54
+
55
+ <button
56
+ type="button"
57
+ onClick={() => queue.remove(item.localId)}
58
+ className="cortex-attachment-chip__remove"
59
+ disabled={item.status === 'deleting'}
60
+ aria-label={t('translate_remove_attachment')}
61
+ >
62
+ <svg viewBox="0 0 16 16" fill="none">
63
+ <path
64
+ d="M4.5 4.5l7 7m0-7l-7 7"
65
+ stroke="currentColor"
66
+ strokeWidth="1.5"
67
+ strokeLinecap="round"
68
+ />
69
+ </svg>
70
+ </button>
71
+ </div>
72
+ ))}
73
+ </div>
74
+ );
75
+ }