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.
Files changed (87) hide show
  1. package/dist/agent/index.d.ts +2 -2
  2. package/dist/agent/index.js +53 -3
  3. package/dist/agent/index.js.map +1 -1
  4. package/dist/cli.js +397 -46
  5. package/dist/cli.js.map +1 -1
  6. package/dist/db/index.d.ts +2 -1
  7. package/dist/db/index.js.map +1 -1
  8. package/dist/{index-BzedNBK-.d.ts → index-CNwLFGiZ.d.ts} +24 -3
  9. package/dist/index.d.ts +4 -4
  10. package/dist/index.js +392 -41
  11. package/dist/index.js.map +1 -1
  12. package/dist/{schema-CkrIadxa.d.ts → schema-Df7MU3nM.d.ts} +26 -3
  13. package/dist/server/index.js +392 -41
  14. package/dist/server/index.js.map +1 -1
  15. package/dist/tools/index.js.map +1 -1
  16. package/package.json +1 -1
  17. package/web/.next/BUILD_ID +1 -1
  18. package/web/.next/standalone/web/.next/BUILD_ID +1 -1
  19. package/web/.next/standalone/web/.next/build-manifest.json +2 -2
  20. package/web/.next/standalone/web/.next/prerender-manifest.json +3 -3
  21. package/web/.next/standalone/web/.next/server/app/(main)/page.js.nft.json +1 -1
  22. package/web/.next/standalone/web/.next/server/app/(main)/page_client-reference-manifest.js +1 -1
  23. package/web/.next/standalone/web/.next/server/app/(main)/session/[id]/page.js.nft.json +1 -1
  24. package/web/.next/standalone/web/.next/server/app/(main)/session/[id]/page_client-reference-manifest.js +1 -1
  25. package/web/.next/standalone/web/.next/server/app/_global-error.html +2 -2
  26. package/web/.next/standalone/web/.next/server/app/_global-error.rsc +1 -1
  27. package/web/.next/standalone/web/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  28. package/web/.next/standalone/web/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  29. package/web/.next/standalone/web/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  30. package/web/.next/standalone/web/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  31. package/web/.next/standalone/web/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  32. package/web/.next/standalone/web/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  33. package/web/.next/standalone/web/.next/server/app/_not-found.html +1 -1
  34. package/web/.next/standalone/web/.next/server/app/_not-found.rsc +2 -2
  35. package/web/.next/standalone/web/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  36. package/web/.next/standalone/web/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  37. package/web/.next/standalone/web/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
  38. package/web/.next/standalone/web/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  39. package/web/.next/standalone/web/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  40. package/web/.next/standalone/web/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  41. package/web/.next/standalone/web/.next/server/app/index.html +1 -1
  42. package/web/.next/standalone/web/.next/server/app/index.rsc +4 -4
  43. package/web/.next/standalone/web/.next/server/app/index.segments/!KG1haW4p/__PAGE__.segment.rsc +2 -2
  44. package/web/.next/standalone/web/.next/server/app/index.segments/!KG1haW4p.segment.rsc +2 -2
  45. package/web/.next/standalone/web/.next/server/app/index.segments/_full.segment.rsc +4 -4
  46. package/web/.next/standalone/web/.next/server/app/index.segments/_head.segment.rsc +1 -1
  47. package/web/.next/standalone/web/.next/server/app/index.segments/_index.segment.rsc +2 -2
  48. package/web/.next/standalone/web/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  49. package/web/.next/standalone/web/.next/server/chunks/ssr/{2374f_1d78db71._.js → 2374f_387a1437._.js} +1 -1
  50. package/web/.next/standalone/web/.next/server/chunks/ssr/{2374f_378282b1._.js → 2374f_5f58fd73._.js} +1 -1
  51. package/web/.next/standalone/web/.next/server/chunks/ssr/{2374f_30f9df13._.js → 2374f_65fcfd95._.js} +1 -1
  52. package/web/.next/standalone/web/.next/server/chunks/ssr/{2374f_8825dcc9._.js → 2374f_741f6b67._.js} +1 -1
  53. package/web/.next/standalone/web/.next/server/chunks/ssr/{2374f_9bf3c7f3._.js → 2374f_814be2c9._.js} +2 -2
  54. package/web/.next/standalone/web/.next/server/chunks/ssr/{2374f_5de336d2._.js → 2374f_84859a94._.js} +1 -1
  55. package/web/.next/standalone/web/.next/server/chunks/ssr/{2374f_bbc99511._.js → 2374f_cfd0137a._.js} +1 -1
  56. package/web/.next/standalone/web/.next/server/chunks/ssr/{2374f_d94c2b70._.js → 2374f_f1038f7c._.js} +1 -1
  57. package/web/.next/standalone/web/.next/server/chunks/ssr/{[root-of-the-server]__a984d933._.js → [root-of-the-server]__3ec22171._.js} +2 -2
  58. package/web/.next/standalone/web/.next/server/chunks/ssr/web_c7618534._.js +8 -0
  59. package/web/.next/standalone/web/.next/server/pages/404.html +1 -1
  60. package/web/.next/standalone/web/.next/server/pages/500.html +2 -2
  61. package/web/.next/standalone/web/.next/server/server-reference-manifest.js +1 -1
  62. package/web/.next/standalone/web/.next/server/server-reference-manifest.json +1 -1
  63. package/web/.next/standalone/web/.next/static/chunks/{a86053f0894587f2.js → 3bb454ca848ec78e.js} +3 -3
  64. package/web/.next/standalone/web/.next/static/chunks/{5ec82ce8f3aabaf0.js → 5e5b485d77ac0d8f.js} +1 -1
  65. package/web/.next/standalone/web/.next/static/chunks/cb355fac10c6ad11.css +1 -0
  66. package/web/.next/standalone/web/.next/static/static/chunks/{a86053f0894587f2.js → 3bb454ca848ec78e.js} +3 -3
  67. package/web/.next/{static/chunks/5ec82ce8f3aabaf0.js → standalone/web/.next/static/static/chunks/5e5b485d77ac0d8f.js} +1 -1
  68. package/web/.next/standalone/web/.next/static/static/chunks/cb355fac10c6ad11.css +1 -0
  69. package/web/.next/standalone/web/src/components/ai-elements/speech-input.tsx +89 -36
  70. package/web/.next/standalone/web/src/components/chat-interface.tsx +353 -37
  71. package/web/.next/standalone/web/src/lib/api.ts +133 -2
  72. package/web/.next/static/chunks/{a86053f0894587f2.js → 3bb454ca848ec78e.js} +3 -3
  73. package/web/.next/{standalone/web/.next/static/static/chunks/5ec82ce8f3aabaf0.js → static/chunks/5e5b485d77ac0d8f.js} +1 -1
  74. package/web/.next/static/chunks/cb355fac10c6ad11.css +1 -0
  75. package/web/.next/standalone/web/.next/server/chunks/ssr/web_19b6934c._.js +0 -8
  76. package/web/.next/standalone/web/.next/static/chunks/d0a69c59b1c0d99c.css +0 -1
  77. package/web/.next/standalone/web/.next/static/static/chunks/d0a69c59b1c0d99c.css +0 -1
  78. package/web/.next/static/chunks/d0a69c59b1c0d99c.css +0 -1
  79. /package/web/.next/standalone/web/.next/static/{kABnAk0Y1tlcrUKDlM8UT → n86r6x1RoUipFp6nLIk-R}/_buildManifest.js +0 -0
  80. /package/web/.next/standalone/web/.next/static/{kABnAk0Y1tlcrUKDlM8UT → n86r6x1RoUipFp6nLIk-R}/_clientMiddlewareManifest.json +0 -0
  81. /package/web/.next/standalone/web/.next/static/{kABnAk0Y1tlcrUKDlM8UT → n86r6x1RoUipFp6nLIk-R}/_ssgManifest.js +0 -0
  82. /package/web/.next/standalone/web/.next/static/static/{kABnAk0Y1tlcrUKDlM8UT → n86r6x1RoUipFp6nLIk-R}/_buildManifest.js +0 -0
  83. /package/web/.next/standalone/web/.next/static/static/{kABnAk0Y1tlcrUKDlM8UT → n86r6x1RoUipFp6nLIk-R}/_clientMiddlewareManifest.json +0 -0
  84. /package/web/.next/standalone/web/.next/static/static/{kABnAk0Y1tlcrUKDlM8UT → n86r6x1RoUipFp6nLIk-R}/_ssgManifest.js +0 -0
  85. /package/web/.next/static/{kABnAk0Y1tlcrUKDlM8UT → n86r6x1RoUipFp6nLIk-R}/_buildManifest.js +0 -0
  86. /package/web/.next/static/{kABnAk0Y1tlcrUKDlM8UT → n86r6x1RoUipFp6nLIk-R}/_clientMiddlewareManifest.json +0 -0
  87. /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: typeof msg.content === 'string' ? msg.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 === event.data.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: event.data.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
- console.error('Failed to load messages:', err);
1003
+ if (!isStale) {
1004
+ console.error('Failed to load messages:', err);
1005
+ }
866
1006
  } finally {
867
- setIsLoadingHistory(false);
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(session.id);
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 () => clearInterval(interval);
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(session.id);
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 () => clearInterval(interval);
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(session.id);
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(session.id);
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(session.id, handleSSEEvent, {
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 (!input.trim() || isRunning) return;
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: input,
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, input, handleSSEEvent, {
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
- <p className="whitespace-pre-wrap">{item.content}</p>
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={handleSubmit}
2341
+ onSubmit={handlePromptSubmit}
2069
2342
  className="shadow-sm"
2343
+ globalDrop
2344
+ multiple
2070
2345
  >
2346
+ <PromptInputHeader>
2347
+ <PromptInputAttachmentsDisplay />
2348
+ </PromptInputHeader>
2071
2349
  <PromptInputBody>
2072
- <PromptInputTextarea
2073
- value={input}
2074
- onChange={(e) => setInput(e.target.value)}
2075
- placeholder="Ask SparkECoder to help..."
2076
- disabled={isRunning}
2077
- autoFocus
2078
- className="min-h-[80px] focus:ring-2 focus:ring-primary/20 transition-all"
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
- <PromptInputSubmit
2084
- disabled={!isRunning && !input.trim()}
2085
- status={isRunning ? 'streaming' : 'ready'}
2086
- onStop={handleStop}
2087
- className="bg-primary hover:bg-primary/90 transition-colors"
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>