sparkecoder 0.1.21 → 0.1.22
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/agent/index.d.ts +2 -2
- package/dist/agent/index.js +53 -3
- package/dist/agent/index.js.map +1 -1
- package/dist/cli.js +397 -46
- package/dist/cli.js.map +1 -1
- package/dist/db/index.d.ts +2 -1
- package/dist/db/index.js.map +1 -1
- package/dist/{index-BzedNBK-.d.ts → index-CNwLFGiZ.d.ts} +24 -3
- package/dist/index.d.ts +4 -4
- package/dist/index.js +392 -41
- package/dist/index.js.map +1 -1
- package/dist/{schema-CkrIadxa.d.ts → schema-Df7MU3nM.d.ts} +26 -3
- package/dist/server/index.js +392 -41
- package/dist/server/index.js.map +1 -1
- package/dist/tools/index.js.map +1 -1
- package/package.json +1 -1
- package/web/.next/BUILD_ID +1 -1
- package/web/.next/standalone/web/.next/BUILD_ID +1 -1
- package/web/.next/standalone/web/.next/build-manifest.json +2 -2
- package/web/.next/standalone/web/.next/prerender-manifest.json +3 -3
- package/web/.next/standalone/web/.next/server/app/(main)/page.js.nft.json +1 -1
- package/web/.next/standalone/web/.next/server/app/(main)/page_client-reference-manifest.js +1 -1
- package/web/.next/standalone/web/.next/server/app/(main)/session/[id]/page.js.nft.json +1 -1
- package/web/.next/standalone/web/.next/server/app/(main)/session/[id]/page_client-reference-manifest.js +1 -1
- package/web/.next/standalone/web/.next/server/app/_global-error.html +2 -2
- package/web/.next/standalone/web/.next/server/app/_global-error.rsc +1 -1
- package/web/.next/standalone/web/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/web/.next/standalone/web/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/web/.next/standalone/web/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/web/.next/standalone/web/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/web/.next/standalone/web/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/web/.next/standalone/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/web/.next/standalone/web/.next/server/app/_not-found.html +1 -1
- package/web/.next/standalone/web/.next/server/app/_not-found.rsc +2 -2
- package/web/.next/standalone/web/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
- package/web/.next/standalone/web/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/web/.next/standalone/web/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
- package/web/.next/standalone/web/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/web/.next/standalone/web/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/web/.next/standalone/web/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/web/.next/standalone/web/.next/server/app/index.html +1 -1
- package/web/.next/standalone/web/.next/server/app/index.rsc +4 -4
- package/web/.next/standalone/web/.next/server/app/index.segments/!KG1haW4p/__PAGE__.segment.rsc +2 -2
- package/web/.next/standalone/web/.next/server/app/index.segments/!KG1haW4p.segment.rsc +2 -2
- package/web/.next/standalone/web/.next/server/app/index.segments/_full.segment.rsc +4 -4
- package/web/.next/standalone/web/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/web/.next/standalone/web/.next/server/app/index.segments/_index.segment.rsc +2 -2
- package/web/.next/standalone/web/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/web/.next/standalone/web/.next/server/chunks/ssr/{2374f_1d78db71._.js → 2374f_387a1437._.js} +1 -1
- package/web/.next/standalone/web/.next/server/chunks/ssr/{2374f_378282b1._.js → 2374f_5f58fd73._.js} +1 -1
- package/web/.next/standalone/web/.next/server/chunks/ssr/{2374f_30f9df13._.js → 2374f_65fcfd95._.js} +1 -1
- package/web/.next/standalone/web/.next/server/chunks/ssr/{2374f_8825dcc9._.js → 2374f_741f6b67._.js} +1 -1
- package/web/.next/standalone/web/.next/server/chunks/ssr/{2374f_9bf3c7f3._.js → 2374f_814be2c9._.js} +2 -2
- package/web/.next/standalone/web/.next/server/chunks/ssr/{2374f_5de336d2._.js → 2374f_84859a94._.js} +1 -1
- package/web/.next/standalone/web/.next/server/chunks/ssr/{2374f_bbc99511._.js → 2374f_cfd0137a._.js} +1 -1
- package/web/.next/standalone/web/.next/server/chunks/ssr/{2374f_d94c2b70._.js → 2374f_f1038f7c._.js} +1 -1
- package/web/.next/standalone/web/.next/server/chunks/ssr/{[root-of-the-server]__a984d933._.js → [root-of-the-server]__3ec22171._.js} +2 -2
- package/web/.next/standalone/web/.next/server/chunks/ssr/web_c7618534._.js +8 -0
- package/web/.next/standalone/web/.next/server/pages/404.html +1 -1
- package/web/.next/standalone/web/.next/server/pages/500.html +2 -2
- package/web/.next/standalone/web/.next/server/server-reference-manifest.js +1 -1
- package/web/.next/standalone/web/.next/server/server-reference-manifest.json +1 -1
- package/web/.next/standalone/web/.next/static/chunks/{a86053f0894587f2.js → 3bb454ca848ec78e.js} +3 -3
- package/web/.next/standalone/web/.next/static/chunks/{5ec82ce8f3aabaf0.js → 5e5b485d77ac0d8f.js} +1 -1
- package/web/.next/standalone/web/.next/static/chunks/cb355fac10c6ad11.css +1 -0
- package/web/.next/standalone/web/.next/static/static/chunks/{a86053f0894587f2.js → 3bb454ca848ec78e.js} +3 -3
- package/web/.next/{static/chunks/5ec82ce8f3aabaf0.js → standalone/web/.next/static/static/chunks/5e5b485d77ac0d8f.js} +1 -1
- package/web/.next/standalone/web/.next/static/static/chunks/cb355fac10c6ad11.css +1 -0
- package/web/.next/standalone/web/src/components/ai-elements/speech-input.tsx +89 -36
- package/web/.next/standalone/web/src/components/chat-interface.tsx +353 -37
- package/web/.next/standalone/web/src/lib/api.ts +133 -2
- package/web/.next/static/chunks/{a86053f0894587f2.js → 3bb454ca848ec78e.js} +3 -3
- package/web/.next/{standalone/web/.next/static/static/chunks/5ec82ce8f3aabaf0.js → static/chunks/5e5b485d77ac0d8f.js} +1 -1
- package/web/.next/static/chunks/cb355fac10c6ad11.css +1 -0
- package/web/.next/standalone/web/.next/server/chunks/ssr/web_19b6934c._.js +0 -8
- package/web/.next/standalone/web/.next/static/chunks/d0a69c59b1c0d99c.css +0 -1
- package/web/.next/standalone/web/.next/static/static/chunks/d0a69c59b1c0d99c.css +0 -1
- package/web/.next/static/chunks/d0a69c59b1c0d99c.css +0 -1
- /package/web/.next/standalone/web/.next/static/{kABnAk0Y1tlcrUKDlM8UT → n86r6x1RoUipFp6nLIk-R}/_buildManifest.js +0 -0
- /package/web/.next/standalone/web/.next/static/{kABnAk0Y1tlcrUKDlM8UT → n86r6x1RoUipFp6nLIk-R}/_clientMiddlewareManifest.json +0 -0
- /package/web/.next/standalone/web/.next/static/{kABnAk0Y1tlcrUKDlM8UT → n86r6x1RoUipFp6nLIk-R}/_ssgManifest.js +0 -0
- /package/web/.next/standalone/web/.next/static/static/{kABnAk0Y1tlcrUKDlM8UT → n86r6x1RoUipFp6nLIk-R}/_buildManifest.js +0 -0
- /package/web/.next/standalone/web/.next/static/static/{kABnAk0Y1tlcrUKDlM8UT → n86r6x1RoUipFp6nLIk-R}/_clientMiddlewareManifest.json +0 -0
- /package/web/.next/standalone/web/.next/static/static/{kABnAk0Y1tlcrUKDlM8UT → n86r6x1RoUipFp6nLIk-R}/_ssgManifest.js +0 -0
- /package/web/.next/static/{kABnAk0Y1tlcrUKDlM8UT → n86r6x1RoUipFp6nLIk-R}/_buildManifest.js +0 -0
- /package/web/.next/static/{kABnAk0Y1tlcrUKDlM8UT → n86r6x1RoUipFp6nLIk-R}/_clientMiddlewareManifest.json +0 -0
- /package/web/.next/static/{kABnAk0Y1tlcrUKDlM8UT → n86r6x1RoUipFp6nLIk-R}/_ssgManifest.js +0 -0
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState, useRef, useEffect } from 'react';
|
|
4
4
|
import Image from 'next/image';
|
|
5
|
+
import { cn } from '@/lib/utils';
|
|
5
6
|
import { Badge } from '@/components/ui/badge';
|
|
6
7
|
import { Button } from '@/components/ui/button';
|
|
7
8
|
import {
|
|
@@ -73,7 +74,16 @@ import {
|
|
|
73
74
|
PromptInputTextarea,
|
|
74
75
|
PromptInputFooter,
|
|
75
76
|
PromptInputSubmit,
|
|
77
|
+
PromptInputHeader,
|
|
78
|
+
usePromptInputAttachments,
|
|
76
79
|
} from '@/components/ai-elements/prompt-input';
|
|
80
|
+
import {
|
|
81
|
+
Attachments,
|
|
82
|
+
Attachment,
|
|
83
|
+
AttachmentPreview,
|
|
84
|
+
AttachmentRemove,
|
|
85
|
+
AttachmentInfo,
|
|
86
|
+
} from '@/components/ai-elements/attachments';
|
|
77
87
|
import { Suggestion, Suggestions } from '@/components/ai-elements/suggestion';
|
|
78
88
|
import {
|
|
79
89
|
runAgent,
|
|
@@ -98,6 +108,7 @@ import {
|
|
|
98
108
|
type TodosResponse,
|
|
99
109
|
type SessionConfig,
|
|
100
110
|
type Checkpoint,
|
|
111
|
+
type RunAgentAttachment,
|
|
101
112
|
} from '@/lib/api';
|
|
102
113
|
import { TodoPanel } from '@/components/ai-elements/todo-panel';
|
|
103
114
|
import { getConfig, type AppConfig } from '@/lib/config';
|
|
@@ -109,7 +120,7 @@ import {
|
|
|
109
120
|
SelectTrigger,
|
|
110
121
|
SelectValue,
|
|
111
122
|
} from '@/components/ui/select';
|
|
112
|
-
import { MessageSquare, Copy, RefreshCw, AlertTriangle, Terminal as TerminalIcon, FileCode, Radio, Pencil, Check, Settings, RotateCcw, FolderOpen, PanelLeft } from 'lucide-react';
|
|
123
|
+
import { MessageSquare, Copy, RefreshCw, AlertTriangle, Terminal as TerminalIcon, FileCode, Radio, Pencil, Check, Settings, RotateCcw, FolderOpen, PanelLeft, FileIcon } from 'lucide-react';
|
|
113
124
|
import { useSidebar } from '@/components/ui/sidebar';
|
|
114
125
|
import {
|
|
115
126
|
Dialog,
|
|
@@ -127,6 +138,7 @@ import { BashTool, type BashInput, type BashOutput } from '@/components/ai-eleme
|
|
|
127
138
|
import { TodoTool, type TodoInput, type TodoOutput } from '@/components/ai-elements/todo-tool';
|
|
128
139
|
import { LoadSkillTool, type LoadSkillInput, type LoadSkillOutput } from '@/components/ai-elements/load-skill-tool';
|
|
129
140
|
import { LinterTool, type LinterInput, type LinterOutput } from '@/components/ai-elements/linter-tool';
|
|
141
|
+
import { SpeechInput } from '@/components/ai-elements/speech-input';
|
|
130
142
|
|
|
131
143
|
interface ToolCallOutput {
|
|
132
144
|
status?: string;
|
|
@@ -154,6 +166,14 @@ interface ToolCallInfo {
|
|
|
154
166
|
liveOutput?: string;
|
|
155
167
|
}
|
|
156
168
|
|
|
169
|
+
/** Attachment stored with user messages */
|
|
170
|
+
interface UserAttachment {
|
|
171
|
+
type: 'image' | 'file';
|
|
172
|
+
data: string; // base64 data URL
|
|
173
|
+
mediaType?: string;
|
|
174
|
+
filename?: string;
|
|
175
|
+
}
|
|
176
|
+
|
|
157
177
|
interface ChatItem {
|
|
158
178
|
id: string;
|
|
159
179
|
type: 'user-message' | 'assistant-text' | 'tool-call' | 'tool-result' | 'reasoning';
|
|
@@ -161,6 +181,8 @@ interface ChatItem {
|
|
|
161
181
|
toolCall?: ToolCallInfo;
|
|
162
182
|
/** For user messages: the message sequence number (used for revert) */
|
|
163
183
|
messageSequence?: number;
|
|
184
|
+
/** For user messages: any attached files/images */
|
|
185
|
+
attachments?: UserAttachment[];
|
|
164
186
|
}
|
|
165
187
|
|
|
166
188
|
interface ChatInterfaceProps {
|
|
@@ -204,9 +226,58 @@ function SidebarToggle() {
|
|
|
204
226
|
);
|
|
205
227
|
}
|
|
206
228
|
|
|
229
|
+
// Component to display attachments in the prompt input
|
|
230
|
+
function PromptInputAttachmentsDisplay() {
|
|
231
|
+
const attachments = usePromptInputAttachments();
|
|
232
|
+
|
|
233
|
+
if (attachments.files.length === 0) {
|
|
234
|
+
return null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return (
|
|
238
|
+
<Attachments variant="inline">
|
|
239
|
+
{attachments.files.map((attachment) => (
|
|
240
|
+
<Attachment
|
|
241
|
+
data={attachment}
|
|
242
|
+
key={attachment.id}
|
|
243
|
+
onRemove={() => attachments.remove(attachment.id)}
|
|
244
|
+
>
|
|
245
|
+
<AttachmentPreview />
|
|
246
|
+
<AttachmentInfo />
|
|
247
|
+
<AttachmentRemove />
|
|
248
|
+
</Attachment>
|
|
249
|
+
))}
|
|
250
|
+
</Attachments>
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Custom submit button that checks both text input and attachments
|
|
255
|
+
function ChatSubmitButton({
|
|
256
|
+
input,
|
|
257
|
+
isRunning,
|
|
258
|
+
onStop
|
|
259
|
+
}: {
|
|
260
|
+
input: string;
|
|
261
|
+
isRunning: boolean;
|
|
262
|
+
onStop: () => void;
|
|
263
|
+
}) {
|
|
264
|
+
const attachments = usePromptInputAttachments();
|
|
265
|
+
const hasContent = input.trim() || attachments.files.length > 0;
|
|
266
|
+
|
|
267
|
+
return (
|
|
268
|
+
<PromptInputSubmit
|
|
269
|
+
disabled={!isRunning && !hasContent}
|
|
270
|
+
status={isRunning ? 'streaming' : 'ready'}
|
|
271
|
+
onStop={onStop}
|
|
272
|
+
className="bg-primary hover:bg-primary/90 transition-colors"
|
|
273
|
+
/>
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
207
277
|
export function ChatInterface({ session }: ChatInterfaceProps) {
|
|
208
278
|
const [chatItems, setChatItems] = useState<ChatItem[]>([]);
|
|
209
279
|
const [input, setInput] = useState('');
|
|
280
|
+
const [interimTranscript, setInterimTranscript] = useState('');
|
|
210
281
|
const [isRunning, setIsRunning] = useState(false);
|
|
211
282
|
const [isWatching, setIsWatching] = useState(false); // True when watching another client's stream
|
|
212
283
|
const [currentStreamId, setCurrentStreamId] = useState<string | null>(null);
|
|
@@ -317,11 +388,40 @@ export function ChatInterface({ session }: ChatInterfaceProps) {
|
|
|
317
388
|
let messageSequence = 0;
|
|
318
389
|
for (const msg of apiMessages) {
|
|
319
390
|
if (msg.role === 'user') {
|
|
391
|
+
// Extract text and attachments from user message
|
|
392
|
+
let textContent = '';
|
|
393
|
+
const attachments: UserAttachment[] = [];
|
|
394
|
+
|
|
395
|
+
if (typeof msg.content === 'string') {
|
|
396
|
+
textContent = msg.content;
|
|
397
|
+
} else if (Array.isArray(msg.content)) {
|
|
398
|
+
for (const part of msg.content) {
|
|
399
|
+
if (part.type === 'text' && 'text' in part) {
|
|
400
|
+
textContent += (part as { type: 'text'; text: string }).text;
|
|
401
|
+
} else if (part.type === 'image' && 'image' in part) {
|
|
402
|
+
const imagePart = part as { type: 'image'; image: string; mediaType?: string };
|
|
403
|
+
attachments.push({
|
|
404
|
+
type: 'image',
|
|
405
|
+
data: imagePart.image,
|
|
406
|
+
mediaType: imagePart.mediaType,
|
|
407
|
+
});
|
|
408
|
+
} else if (part.type === 'file' && 'data' in part) {
|
|
409
|
+
const filePart = part as { type: 'file'; data: string; mediaType?: string };
|
|
410
|
+
attachments.push({
|
|
411
|
+
type: 'file',
|
|
412
|
+
data: filePart.data,
|
|
413
|
+
mediaType: filePart.mediaType,
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
320
419
|
items.push({
|
|
321
420
|
id: msg.id,
|
|
322
421
|
type: 'user-message',
|
|
323
|
-
content:
|
|
422
|
+
content: textContent,
|
|
324
423
|
messageSequence, // Track sequence for revert
|
|
424
|
+
attachments: attachments.length > 0 ? attachments : undefined,
|
|
325
425
|
});
|
|
326
426
|
} else if (msg.role === 'assistant') {
|
|
327
427
|
if (typeof msg.content === 'string') {
|
|
@@ -529,10 +629,36 @@ export function ChatInterface({ session }: ChatInterfaceProps) {
|
|
|
529
629
|
// Skip if this client initiated the stream (we already added the message in handleSubmit)
|
|
530
630
|
if (event.data?.content && !isStreamInitiatorRef.current) {
|
|
531
631
|
setChatItems((prev) => {
|
|
632
|
+
// Parse content - can be string or array with text/image/file parts
|
|
633
|
+
let textContent = '';
|
|
634
|
+
const attachments: UserAttachment[] = [];
|
|
635
|
+
|
|
636
|
+
const rawContent = event.data.content;
|
|
637
|
+
if (typeof rawContent === 'string') {
|
|
638
|
+
textContent = rawContent;
|
|
639
|
+
} else if (Array.isArray(rawContent)) {
|
|
640
|
+
for (const part of rawContent as Array<{ type: string; text?: string; image?: string; data?: string; mediaType?: string }>) {
|
|
641
|
+
if (part.type === 'text' && part.text) {
|
|
642
|
+
textContent += part.text;
|
|
643
|
+
} else if (part.type === 'image' && part.image) {
|
|
644
|
+
attachments.push({
|
|
645
|
+
type: 'image',
|
|
646
|
+
data: part.image,
|
|
647
|
+
mediaType: part.mediaType,
|
|
648
|
+
});
|
|
649
|
+
} else if (part.type === 'file' && part.data) {
|
|
650
|
+
attachments.push({
|
|
651
|
+
type: 'file',
|
|
652
|
+
data: part.data,
|
|
653
|
+
mediaType: part.mediaType,
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
532
659
|
// Check if we already have a user message with this content
|
|
533
|
-
// (handles ID mismatches between API-loaded messages and SSE events)
|
|
534
660
|
const contentExists = prev.some(
|
|
535
|
-
(item) => item.type === 'user-message' && item.content ===
|
|
661
|
+
(item) => item.type === 'user-message' && item.content === textContent
|
|
536
662
|
);
|
|
537
663
|
if (contentExists) return prev;
|
|
538
664
|
|
|
@@ -540,7 +666,8 @@ export function ChatInterface({ session }: ChatInterfaceProps) {
|
|
|
540
666
|
return [...prev, {
|
|
541
667
|
id: messageId,
|
|
542
668
|
type: 'user-message',
|
|
543
|
-
content:
|
|
669
|
+
content: textContent,
|
|
670
|
+
attachments: attachments.length > 0 ? attachments : undefined,
|
|
544
671
|
}];
|
|
545
672
|
});
|
|
546
673
|
}
|
|
@@ -818,6 +945,9 @@ export function ChatInterface({ session }: ChatInterfaceProps) {
|
|
|
818
945
|
|
|
819
946
|
// Load messages and check for active streams when session changes
|
|
820
947
|
useEffect(() => {
|
|
948
|
+
// Track if this effect is stale (session changed during async work)
|
|
949
|
+
let isStale = false;
|
|
950
|
+
|
|
821
951
|
const loadMessagesAndCheckStream = async () => {
|
|
822
952
|
setIsLoadingHistory(true);
|
|
823
953
|
setChatItems([]);
|
|
@@ -838,6 +968,10 @@ export function ChatInterface({ session }: ChatInterfaceProps) {
|
|
|
838
968
|
getSessionMessages(session.id),
|
|
839
969
|
getSessionCheckpoints(session.id).catch(() => ({ checkpoints: [] })),
|
|
840
970
|
]);
|
|
971
|
+
|
|
972
|
+
// Don't update state if session changed during async work
|
|
973
|
+
if (isStale) return;
|
|
974
|
+
|
|
841
975
|
const sorted = [...apiMessages].sort((a, b) =>
|
|
842
976
|
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
|
843
977
|
);
|
|
@@ -847,6 +981,10 @@ export function ChatInterface({ session }: ChatInterfaceProps) {
|
|
|
847
981
|
|
|
848
982
|
// Check if there's an active stream to watch
|
|
849
983
|
const streamInfo = await getActiveStream(session.id);
|
|
984
|
+
|
|
985
|
+
// Check again after await
|
|
986
|
+
if (isStale) return;
|
|
987
|
+
|
|
850
988
|
if (streamInfo.hasActiveStream && streamInfo.stream) {
|
|
851
989
|
console.log('Found active stream, connecting...', streamInfo.stream.streamId);
|
|
852
990
|
isStreamInitiatorRef.current = false; // We're watching, not initiating
|
|
@@ -862,9 +1000,13 @@ export function ChatInterface({ session }: ChatInterfaceProps) {
|
|
|
862
1000
|
cancelRef.current = cancel;
|
|
863
1001
|
}
|
|
864
1002
|
} catch (err) {
|
|
865
|
-
|
|
1003
|
+
if (!isStale) {
|
|
1004
|
+
console.error('Failed to load messages:', err);
|
|
1005
|
+
}
|
|
866
1006
|
} finally {
|
|
867
|
-
|
|
1007
|
+
if (!isStale) {
|
|
1008
|
+
setIsLoadingHistory(false);
|
|
1009
|
+
}
|
|
868
1010
|
}
|
|
869
1011
|
};
|
|
870
1012
|
|
|
@@ -875,6 +1017,7 @@ export function ChatInterface({ session }: ChatInterfaceProps) {
|
|
|
875
1017
|
|
|
876
1018
|
// Cleanup on unmount or session change
|
|
877
1019
|
return () => {
|
|
1020
|
+
isStale = true; // Mark as stale so async work doesn't update state
|
|
878
1021
|
if (cancelRef.current) {
|
|
879
1022
|
cancelRef.current();
|
|
880
1023
|
cancelRef.current = null;
|
|
@@ -887,9 +1030,15 @@ export function ChatInterface({ session }: ChatInterfaceProps) {
|
|
|
887
1030
|
|
|
888
1031
|
// Check for pending approvals - API is source of truth
|
|
889
1032
|
useEffect(() => {
|
|
1033
|
+
// Track if this effect is stale (session changed during async work)
|
|
1034
|
+
let isStale = false;
|
|
1035
|
+
const currentSessionId = session.id;
|
|
1036
|
+
|
|
890
1037
|
const checkApprovals = async () => {
|
|
891
1038
|
try {
|
|
892
|
-
const approvals = await getPendingApprovals(
|
|
1039
|
+
const approvals = await getPendingApprovals(currentSessionId);
|
|
1040
|
+
// Don't update state if session changed during async work
|
|
1041
|
+
if (isStale) return;
|
|
893
1042
|
// Use API response as source of truth - this correctly filters out
|
|
894
1043
|
// already-handled approvals that might have come from SSE replay
|
|
895
1044
|
setPendingApprovals(approvals);
|
|
@@ -900,14 +1049,23 @@ export function ChatInterface({ session }: ChatInterfaceProps) {
|
|
|
900
1049
|
// Initial check
|
|
901
1050
|
checkApprovals();
|
|
902
1051
|
const interval = setInterval(checkApprovals, 2000);
|
|
903
|
-
return () =>
|
|
1052
|
+
return () => {
|
|
1053
|
+
isStale = true;
|
|
1054
|
+
clearInterval(interval);
|
|
1055
|
+
};
|
|
904
1056
|
}, [session.id]);
|
|
905
1057
|
|
|
906
1058
|
// Poll for todos - more frequently when running
|
|
907
1059
|
useEffect(() => {
|
|
1060
|
+
// Track if this effect is stale (session changed during async work)
|
|
1061
|
+
let isStale = false;
|
|
1062
|
+
const currentSessionId = session.id;
|
|
1063
|
+
|
|
908
1064
|
const checkTodos = async () => {
|
|
909
1065
|
try {
|
|
910
|
-
const data = await getSessionTodos(
|
|
1066
|
+
const data = await getSessionTodos(currentSessionId);
|
|
1067
|
+
// Don't update state if session changed during async work
|
|
1068
|
+
if (isStale) return;
|
|
911
1069
|
setTodosData(data);
|
|
912
1070
|
} catch {
|
|
913
1071
|
// Ignore errors
|
|
@@ -918,12 +1076,20 @@ export function ChatInterface({ session }: ChatInterfaceProps) {
|
|
|
918
1076
|
// Poll every 2 seconds when running, 5 seconds otherwise
|
|
919
1077
|
const pollInterval = isRunning ? 1000 : 5000;
|
|
920
1078
|
const interval = setInterval(checkTodos, pollInterval);
|
|
921
|
-
return () =>
|
|
1079
|
+
return () => {
|
|
1080
|
+
isStale = true;
|
|
1081
|
+
clearInterval(interval);
|
|
1082
|
+
};
|
|
922
1083
|
}, [session.id, isRunning]);
|
|
923
1084
|
|
|
924
1085
|
// Poll for new active streams when not currently streaming
|
|
925
1086
|
// This allows auto-attaching to streams started from CLI or other tabs
|
|
926
1087
|
useEffect(() => {
|
|
1088
|
+
// Track if this effect is stale (session changed during async work)
|
|
1089
|
+
let isStale = false;
|
|
1090
|
+
// Capture session.id for async closure
|
|
1091
|
+
const currentSessionId = session.id;
|
|
1092
|
+
|
|
927
1093
|
const checkForNewStream = async () => {
|
|
928
1094
|
// Skip if we're already running, watching, or connecting
|
|
929
1095
|
if (isRunning || isConnectingRef.current) {
|
|
@@ -931,7 +1097,10 @@ export function ChatInterface({ session }: ChatInterfaceProps) {
|
|
|
931
1097
|
}
|
|
932
1098
|
|
|
933
1099
|
try {
|
|
934
|
-
const streamInfo = await getActiveStream(
|
|
1100
|
+
const streamInfo = await getActiveStream(currentSessionId);
|
|
1101
|
+
|
|
1102
|
+
// Don't process if session changed
|
|
1103
|
+
if (isStale) return;
|
|
935
1104
|
|
|
936
1105
|
if (streamInfo.hasActiveStream && streamInfo.stream) {
|
|
937
1106
|
const newStreamId = streamInfo.stream.streamId;
|
|
@@ -948,7 +1117,14 @@ export function ChatInterface({ session }: ChatInterfaceProps) {
|
|
|
948
1117
|
// Refresh messages from server to get the user message that triggered this stream
|
|
949
1118
|
// This ensures we see the user message even if we missed the SSE event
|
|
950
1119
|
try {
|
|
951
|
-
const apiMessages = await getSessionMessages(
|
|
1120
|
+
const apiMessages = await getSessionMessages(currentSessionId);
|
|
1121
|
+
|
|
1122
|
+
// Check again after await
|
|
1123
|
+
if (isStale) {
|
|
1124
|
+
isConnectingRef.current = false;
|
|
1125
|
+
return;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
952
1128
|
const sorted = [...apiMessages].sort((a, b) =>
|
|
953
1129
|
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
|
954
1130
|
);
|
|
@@ -958,13 +1134,19 @@ export function ChatInterface({ session }: ChatInterfaceProps) {
|
|
|
958
1134
|
console.error('Failed to refresh messages:', err);
|
|
959
1135
|
}
|
|
960
1136
|
|
|
1137
|
+
// Final stale check before setting up stream
|
|
1138
|
+
if (isStale) {
|
|
1139
|
+
isConnectingRef.current = false;
|
|
1140
|
+
return;
|
|
1141
|
+
}
|
|
1142
|
+
|
|
961
1143
|
setIsWatching(true);
|
|
962
1144
|
setIsRunning(true);
|
|
963
1145
|
setCurrentStreamId(newStreamId);
|
|
964
1146
|
lastKnownStreamIdRef.current = newStreamId;
|
|
965
1147
|
|
|
966
1148
|
// Start watching the stream
|
|
967
|
-
const cancel = watchStream(
|
|
1149
|
+
const cancel = watchStream(currentSessionId, handleSSEEvent, {
|
|
968
1150
|
streamId: newStreamId,
|
|
969
1151
|
onStreamId: (id) => {
|
|
970
1152
|
setCurrentStreamId(id);
|
|
@@ -985,17 +1167,28 @@ export function ChatInterface({ session }: ChatInterfaceProps) {
|
|
|
985
1167
|
const interval = setInterval(checkForNewStream, 1000);
|
|
986
1168
|
|
|
987
1169
|
return () => {
|
|
1170
|
+
isStale = true; // Mark as stale so async work doesn't update state
|
|
988
1171
|
clearInterval(interval);
|
|
989
1172
|
};
|
|
990
1173
|
}, [session.id, isRunning]);
|
|
991
1174
|
|
|
992
|
-
const handleSubmit = () => {
|
|
993
|
-
if (!
|
|
1175
|
+
const handleSubmit = (promptText: string, attachments?: RunAgentAttachment[]) => {
|
|
1176
|
+
if (!promptText.trim() && (!attachments || attachments.length === 0)) return;
|
|
1177
|
+
if (isRunning) return;
|
|
1178
|
+
|
|
1179
|
+
// Convert RunAgentAttachment to UserAttachment for display
|
|
1180
|
+
const userAttachments: UserAttachment[] | undefined = attachments?.map((a) => ({
|
|
1181
|
+
type: a.type,
|
|
1182
|
+
data: a.data,
|
|
1183
|
+
mediaType: a.mediaType,
|
|
1184
|
+
filename: a.filename,
|
|
1185
|
+
}));
|
|
994
1186
|
|
|
995
1187
|
const userItem: ChatItem = {
|
|
996
1188
|
id: `user-${Date.now()}`,
|
|
997
1189
|
type: 'user-message',
|
|
998
|
-
content:
|
|
1190
|
+
content: promptText || '',
|
|
1191
|
+
attachments: userAttachments,
|
|
999
1192
|
};
|
|
1000
1193
|
|
|
1001
1194
|
setChatItems((prev) => [...prev, userItem]);
|
|
@@ -1011,14 +1204,60 @@ export function ChatInterface({ session }: ChatInterfaceProps) {
|
|
|
1011
1204
|
currentReasoningRef.current = '';
|
|
1012
1205
|
toolCallsRef.current = [];
|
|
1013
1206
|
|
|
1014
|
-
const cancel = runAgent(session.id,
|
|
1207
|
+
const cancel = runAgent(session.id, promptText || 'Please analyze the attached files.', handleSSEEvent, {
|
|
1015
1208
|
onStreamId: (id) => setCurrentStreamId(id),
|
|
1209
|
+
attachments,
|
|
1016
1210
|
});
|
|
1017
1211
|
|
|
1018
1212
|
// Store the cancel function so we can call it from the stop button
|
|
1019
1213
|
cancelRef.current = cancel;
|
|
1020
1214
|
};
|
|
1021
1215
|
|
|
1216
|
+
// Handler for PromptInput that handles file attachments
|
|
1217
|
+
const handlePromptSubmit = async (message: { text: string; files: Array<{ url?: string; filename?: string; mediaType?: string }> }) => {
|
|
1218
|
+
if (isRunning) return;
|
|
1219
|
+
|
|
1220
|
+
const hasText = Boolean(message.text?.trim());
|
|
1221
|
+
const hasFiles = Boolean(message.files?.length);
|
|
1222
|
+
|
|
1223
|
+
if (!hasText && !hasFiles) return;
|
|
1224
|
+
|
|
1225
|
+
// Convert files to attachments for the API
|
|
1226
|
+
const attachments: RunAgentAttachment[] = [];
|
|
1227
|
+
|
|
1228
|
+
if (hasFiles && message.files.length > 0) {
|
|
1229
|
+
for (const file of message.files) {
|
|
1230
|
+
if (!file.url) continue;
|
|
1231
|
+
|
|
1232
|
+
try {
|
|
1233
|
+
// Fetch the blob and convert to base64
|
|
1234
|
+
const response = await fetch(file.url);
|
|
1235
|
+
const blob = await response.blob();
|
|
1236
|
+
const base64 = await new Promise<string>((resolve) => {
|
|
1237
|
+
const reader = new FileReader();
|
|
1238
|
+
reader.onloadend = () => resolve(reader.result as string);
|
|
1239
|
+
reader.readAsDataURL(blob);
|
|
1240
|
+
});
|
|
1241
|
+
|
|
1242
|
+
// Determine if it's an image or file
|
|
1243
|
+
const mediaType = file.mediaType || blob.type || 'application/octet-stream';
|
|
1244
|
+
const isImage = mediaType.startsWith('image/');
|
|
1245
|
+
|
|
1246
|
+
attachments.push({
|
|
1247
|
+
type: isImage ? 'image' : 'file',
|
|
1248
|
+
data: base64,
|
|
1249
|
+
mediaType,
|
|
1250
|
+
filename: file.filename,
|
|
1251
|
+
});
|
|
1252
|
+
} catch (err) {
|
|
1253
|
+
console.error('Failed to process attachment:', err);
|
|
1254
|
+
}
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
handleSubmit(message.text || '', attachments.length > 0 ? attachments : undefined);
|
|
1259
|
+
};
|
|
1260
|
+
|
|
1022
1261
|
const handleStop = async () => {
|
|
1023
1262
|
// Send abort request to server - this stops the agent properly
|
|
1024
1263
|
// The agent will send an abort event back through the stream
|
|
@@ -1070,29 +1309,34 @@ export function ChatInterface({ session }: ChatInterfaceProps) {
|
|
|
1070
1309
|
};
|
|
1071
1310
|
|
|
1072
1311
|
const handleApprove = async (approval: PendingApproval) => {
|
|
1312
|
+
// Always remove from UI immediately - if it's stale, it shouldn't be shown anyway
|
|
1313
|
+
setPendingApprovals((prev) => prev.filter((a) => a.id !== approval.id));
|
|
1073
1314
|
try {
|
|
1074
1315
|
await approveExecution(session.id, approval.toolCallId);
|
|
1075
|
-
setPendingApprovals((prev) => prev.filter((a) => a.id !== approval.id));
|
|
1076
1316
|
} catch (err) {
|
|
1077
1317
|
console.error('Failed to approve:', err);
|
|
1318
|
+
// Don't add back - the API poll will restore it if it's still valid
|
|
1078
1319
|
}
|
|
1079
1320
|
};
|
|
1080
1321
|
|
|
1081
1322
|
const handleReject = async (approval: PendingApproval) => {
|
|
1323
|
+
// Always remove from UI immediately - if it's stale, it shouldn't be shown anyway
|
|
1324
|
+
setPendingApprovals((prev) => prev.filter((a) => a.id !== approval.id));
|
|
1082
1325
|
try {
|
|
1083
1326
|
await rejectExecution(session.id, approval.toolCallId, 'User rejected');
|
|
1084
|
-
setPendingApprovals((prev) => prev.filter((a) => a.id !== approval.id));
|
|
1085
1327
|
} catch (err) {
|
|
1086
1328
|
console.error('Failed to reject:', err);
|
|
1329
|
+
// Don't add back - the API poll will restore it if it's still valid
|
|
1087
1330
|
}
|
|
1088
1331
|
};
|
|
1089
1332
|
|
|
1090
1333
|
// Handle "Don't show again" - approve and disable approval for this tool
|
|
1091
1334
|
const handleApproveAndDisable = async (approval: PendingApproval) => {
|
|
1335
|
+
// Always remove from UI immediately - if it's stale, it shouldn't be shown anyway
|
|
1336
|
+
setPendingApprovals((prev) => prev.filter((a) => a.id !== approval.id));
|
|
1092
1337
|
try {
|
|
1093
1338
|
// First approve this execution
|
|
1094
1339
|
await approveExecution(session.id, approval.toolCallId);
|
|
1095
|
-
setPendingApprovals((prev) => prev.filter((a) => a.id !== approval.id));
|
|
1096
1340
|
|
|
1097
1341
|
// Then disable approval for this tool in session config
|
|
1098
1342
|
const updatedSession = await updateToolApproval(
|
|
@@ -1106,6 +1350,7 @@ export function ChatInterface({ session }: ChatInterfaceProps) {
|
|
|
1106
1350
|
setSessionConfig(updatedSession.config || {});
|
|
1107
1351
|
} catch (err) {
|
|
1108
1352
|
console.error('Failed to approve and disable:', err);
|
|
1353
|
+
// Don't add back - the API poll will restore it if it's still valid
|
|
1109
1354
|
}
|
|
1110
1355
|
};
|
|
1111
1356
|
|
|
@@ -1717,7 +1962,35 @@ export function ChatInterface({ session }: ChatInterfaceProps) {
|
|
|
1717
1962
|
return (
|
|
1718
1963
|
<Message key={item.id} from="user">
|
|
1719
1964
|
<MessageContent>
|
|
1720
|
-
|
|
1965
|
+
{/* Display attachments if any */}
|
|
1966
|
+
{item.attachments && item.attachments.length > 0 && (
|
|
1967
|
+
<div className="flex flex-wrap gap-2 mb-2">
|
|
1968
|
+
{item.attachments.map((attachment, idx) => (
|
|
1969
|
+
<div key={idx} className="relative">
|
|
1970
|
+
{attachment.type === 'image' ? (
|
|
1971
|
+
<div className="relative rounded-lg overflow-hidden border border-border/50 max-w-[200px]">
|
|
1972
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
1973
|
+
<img
|
|
1974
|
+
src={attachment.data.startsWith('data:') ? attachment.data : `data:${attachment.mediaType || 'image/png'};base64,${attachment.data}`}
|
|
1975
|
+
alt={attachment.filename || 'Image attachment'}
|
|
1976
|
+
className="max-w-full h-auto max-h-[150px] object-contain"
|
|
1977
|
+
/>
|
|
1978
|
+
</div>
|
|
1979
|
+
) : (
|
|
1980
|
+
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-muted/50 border border-border/50">
|
|
1981
|
+
<FileIcon className="h-4 w-4 text-muted-foreground" />
|
|
1982
|
+
<span className="text-sm text-muted-foreground truncate max-w-[150px]">
|
|
1983
|
+
{attachment.filename || 'File'}
|
|
1984
|
+
</span>
|
|
1985
|
+
</div>
|
|
1986
|
+
)}
|
|
1987
|
+
</div>
|
|
1988
|
+
))}
|
|
1989
|
+
</div>
|
|
1990
|
+
)}
|
|
1991
|
+
{item.content && (
|
|
1992
|
+
<p className="whitespace-pre-wrap">{item.content}</p>
|
|
1993
|
+
)}
|
|
1721
1994
|
</MessageContent>
|
|
1722
1995
|
{hasCheckpoint && !isRunning && (
|
|
1723
1996
|
<MessageActions className="justify-end mt-1">
|
|
@@ -2065,27 +2338,70 @@ export function ChatInterface({ session }: ChatInterfaceProps) {
|
|
|
2065
2338
|
)}
|
|
2066
2339
|
|
|
2067
2340
|
<PromptInput
|
|
2068
|
-
onSubmit={
|
|
2341
|
+
onSubmit={handlePromptSubmit}
|
|
2069
2342
|
className="shadow-sm"
|
|
2343
|
+
globalDrop
|
|
2344
|
+
multiple
|
|
2070
2345
|
>
|
|
2346
|
+
<PromptInputHeader>
|
|
2347
|
+
<PromptInputAttachmentsDisplay />
|
|
2348
|
+
</PromptInputHeader>
|
|
2071
2349
|
<PromptInputBody>
|
|
2072
|
-
<
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2350
|
+
<div className="relative w-full">
|
|
2351
|
+
<PromptInputTextarea
|
|
2352
|
+
value={input + (interimTranscript ? (input && !input.endsWith(' ') && !input.endsWith('\n') ? ' ' : '') + interimTranscript : '')}
|
|
2353
|
+
onChange={(e) => {
|
|
2354
|
+
// Only update if not currently showing interim transcript
|
|
2355
|
+
if (!interimTranscript) {
|
|
2356
|
+
setInput(e.target.value);
|
|
2357
|
+
} else {
|
|
2358
|
+
// If user types while interim is showing, clear interim and use their input
|
|
2359
|
+
setInterimTranscript('');
|
|
2360
|
+
setInput(e.target.value);
|
|
2361
|
+
}
|
|
2362
|
+
}}
|
|
2363
|
+
placeholder="Ask SparkECoder to help... (drag & drop files/images here)"
|
|
2364
|
+
disabled={isRunning}
|
|
2365
|
+
autoFocus
|
|
2366
|
+
className={cn(
|
|
2367
|
+
"min-h-[80px] focus:ring-2 focus:ring-primary/20 transition-all",
|
|
2368
|
+
interimTranscript && "caret-red-500"
|
|
2369
|
+
)}
|
|
2370
|
+
/>
|
|
2371
|
+
{/* Live transcription indicator */}
|
|
2372
|
+
{interimTranscript && (
|
|
2373
|
+
<div className="absolute bottom-2 right-2 flex items-center gap-1.5 text-xs text-red-500 bg-background/80 backdrop-blur-sm px-2 py-1 rounded-full">
|
|
2374
|
+
<span className="size-2 bg-red-500 rounded-full animate-pulse" />
|
|
2375
|
+
Listening...
|
|
2376
|
+
</div>
|
|
2377
|
+
)}
|
|
2378
|
+
</div>
|
|
2080
2379
|
</PromptInputBody>
|
|
2081
2380
|
<PromptInputFooter>
|
|
2082
2381
|
<div className="flex-1" />
|
|
2083
|
-
<
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2382
|
+
<div className="flex items-center gap-2">
|
|
2383
|
+
<SpeechInput
|
|
2384
|
+
size="icon"
|
|
2385
|
+
className="size-9"
|
|
2386
|
+
onTranscriptionChange={(text) => {
|
|
2387
|
+
// Add finalized transcript to input
|
|
2388
|
+
setInput((prev) => {
|
|
2389
|
+
const needsSpace = prev && !prev.endsWith(' ') && !prev.endsWith('\n');
|
|
2390
|
+
return prev + (needsSpace ? ' ' : '') + text;
|
|
2391
|
+
});
|
|
2392
|
+
}}
|
|
2393
|
+
onInterimTranscription={(text) => {
|
|
2394
|
+
// Show live preview of what's being spoken
|
|
2395
|
+
setInterimTranscript(text);
|
|
2396
|
+
}}
|
|
2397
|
+
disabled={isRunning}
|
|
2398
|
+
/>
|
|
2399
|
+
<ChatSubmitButton
|
|
2400
|
+
input={input + interimTranscript}
|
|
2401
|
+
isRunning={isRunning}
|
|
2402
|
+
onStop={handleStop}
|
|
2403
|
+
/>
|
|
2404
|
+
</div>
|
|
2089
2405
|
</PromptInputFooter>
|
|
2090
2406
|
</PromptInput>
|
|
2091
2407
|
</div>
|