@widgetic/chat 0.1.4 → 0.1.5

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.
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import { onMount, onDestroy } from 'svelte';
2
+ import { onMount, onDestroy, untrack } from 'svelte';
3
3
  import { chatStore, getCurrentMessages } from '../stores/chatStore.js';
4
4
  import { createChatService } from '../services/chatService.js';
5
5
  import { setLogsEnabled, chatLog, chatWarn } from '../utils/logger.js';
@@ -42,7 +42,7 @@
42
42
  conversationId: string,
43
43
  content: string,
44
44
  messageType: string,
45
- attachments?: Array<{ url?: string; file_type?: string; file_name?: string | null; file_size?: number | null; display_order?: number }>,
45
+ attachments?: Array<{ url?: string; file?: File; file_type?: string; file_name?: string | null; file_size?: number | null; display_order?: number }>,
46
46
  ) => Promise<any>;
47
47
  onRestoreCheckpointBackend?: (conversationId: string, checkpointMessageId: string) => Promise<any>;
48
48
  onTakeScreenshot?: () => Promise<string | null>;
@@ -77,8 +77,8 @@
77
77
  * internal messages area grows/shrinks within the current chat height.
78
78
  */
79
79
  resizeExpandsContainer?: boolean;
80
- /** Delta in pixels for the current drag frame (positive = grew downward). */
81
- onMessagesResize?: (deltaPx: number) => void;
80
+ /** Delta in pixels, plus the resulting chat container height (for parent-owned resize). */
81
+ onMessagesResize?: (deltaPx: number, nextContainerHeightPx?: number) => void;
82
82
  }
83
83
 
84
84
  // Expose methods to parent component
@@ -110,6 +110,10 @@
110
110
  updateLatestCodegenStatusMessage: (content: string) => void;
111
111
  /** Reload messages from backend and refresh widget HEAD marker. */
112
112
  reloadConversationMessages: () => Promise<void>;
113
+ /** Select an existing conversation (loads its messages into the chat). */
114
+ selectConversation: (conversationId: string) => Promise<void>;
115
+ /** Replace attachments on the latest user message (CDN URLs after persist). */
116
+ updateLastUserMessageAttachments: (attachments: Array<{ id?: string; url: string; file_type?: string; file_name?: string | null }>) => void;
113
117
  }
114
118
 
115
119
  let { context, config = {}, class: className = '', style = '', isCompleted = false, onStatsUpdate, onChatReady, onCompletionToggle, onSendMessage, onLoadConversations, onLoadMessages, onCreateConversation, onSaveMessage, onRestoreCheckpointBackend, onTakeScreenshot, onConversationChange, onDraftChange, onRetryMessage, onUploadChatFile, showHeader = true, headerTitle = '', showDescriptionHeader = true, showLogs = true, resizeExpandsContainer = false, onMessagesResize }: Props = $props();
@@ -170,67 +174,115 @@
170
174
  /** Prefer a conversation that already has messages (avoids empty auto-created convos). */
171
175
  async function activateBestConversation(conversations: any[]): Promise<void> {
172
176
  for (const conversation of conversations) {
177
+ const conversationId = conversation.id || conversation.conversation_id || conversation.conversationId;
178
+ if (!conversationId) continue;
173
179
  const loadedMessages = onLoadMessages
174
- ? await onLoadMessages(conversation.id)
175
- : await chatService.getMessages(conversation.id);
180
+ ? await onLoadMessages(conversationId)
181
+ : await chatService.getMessages(conversationId);
176
182
  const messageList = loadedMessages || [];
177
- chatStore.actions.setMessages(conversation.id, messageList);
183
+ chatStore.actions.setMessages(conversationId, messageList);
178
184
 
179
185
  if (messageList.length > 0) {
180
- currentConversationId = conversation.id;
186
+ currentConversationId = conversationId;
181
187
  markWidgetHeadAtLatestCommit();
182
- chatLog('[Chat] Restored conversation with history:', conversation.id, messageList.length, 'messages');
188
+ chatLog('[Chat] Restored conversation with history:', conversationId, messageList.length, 'messages');
183
189
  return;
184
190
  }
185
191
  }
186
192
 
187
193
  const fallbackConversation = conversations[0];
188
- chatLog('[Chat] All conversations empty — using latest:', fallbackConversation.id);
189
- currentConversationId = fallbackConversation.id;
194
+ const fallbackId =
195
+ fallbackConversation?.id ||
196
+ fallbackConversation?.conversation_id ||
197
+ fallbackConversation?.conversationId;
198
+ chatLog('[Chat] All conversations empty — using latest:', fallbackId);
199
+ currentConversationId = fallbackId || null;
200
+ }
201
+
202
+ let lastLoadedContextId = $state('');
203
+
204
+ async function initializeForContext(contextId: string) {
205
+ if (!contextId) return;
206
+ lastLoadedContextId = contextId;
207
+ chatLog('[Chat] Initializing with context:', contextId);
208
+ chatStore.actions.setActiveContext(contextId, true);
209
+
210
+ let loaded: any[] | null = null;
211
+ for (let attempt = 0; attempt < 4; attempt++) {
212
+ loaded = await loadConversations();
213
+ if (loaded !== null) break;
214
+ if (attempt < 3) {
215
+ await new Promise((resolve) => setTimeout(resolve, 400 * (attempt + 1)));
216
+ }
217
+ }
218
+
219
+ if (loaded === null) {
220
+ chatStore.actions.updateUI({
221
+ errorMessage: 'Could not load chat history.',
222
+ });
223
+ return;
224
+ }
225
+
226
+ if (loaded.length === 0) {
227
+ chatLog('[Chat] No existing conversations, creating new one');
228
+ await createNewConversation();
229
+ } else {
230
+ chatLog('[Chat] Selecting from', loaded.length, 'conversation(s)');
231
+ await activateBestConversation(loaded);
232
+ }
233
+ chatLog('[Chat] Initialized successfully');
234
+ }
235
+
236
+ async function retryLoadHistory() {
237
+ chatLog('[Chat] Retry load history for', context?.id);
238
+ chatStore.actions.updateUI({ errorMessage: null });
239
+ lastLoadedContextId = '';
240
+ try {
241
+ await initializeForContext(context.id);
242
+ } catch (error) {
243
+ console.error('[Chat] Retry load history failed:', error);
244
+ chatStore.actions.updateUI({
245
+ errorMessage: 'Could not load chat history.',
246
+ });
247
+ }
190
248
  }
191
249
 
192
250
  // Lifecycle
193
251
  onMount(async () => {
194
252
  try {
195
- chatLog('[Chat] Initializing with context:', context.id);
196
-
197
- chatStore.actions.setActiveContext(context.id, true);
198
-
199
- let loaded: any[] | null = null;
200
- for (let attempt = 0; attempt < 4; attempt++) {
201
- loaded = await loadConversations();
202
- if (loaded !== null) break;
203
- if (attempt < 3) {
204
- await new Promise((resolve) => setTimeout(resolve, 400 * (attempt + 1)));
205
- }
206
- }
207
-
208
- if (loaded === null) {
209
- chatStore.actions.updateUI({
210
- errorMessage: 'Could not load chat history. Try refreshing the page.',
211
- });
212
- isInitialized = true;
213
- return;
214
- }
215
-
216
- if (loaded.length === 0) {
217
- chatLog('[Chat] No existing conversations, creating new one');
218
- await createNewConversation();
219
- } else {
220
- chatLog('[Chat] Selecting from', loaded.length, 'conversation(s)');
221
- await activateBestConversation(loaded);
222
- }
223
-
253
+ await initializeForContext(context.id);
224
254
  isInitialized = true;
225
- chatLog('[Chat] Initialized successfully');
226
255
  } catch (error) {
227
256
  console.error('[Chat] Failed to initialize:', error);
228
- chatStore.actions.updateUI({
229
- errorMessage: 'Failed to initialize chat. Please try again.'
257
+ chatStore.actions.updateUI({
258
+ errorMessage: 'Failed to initialize chat. Please try again.',
230
259
  });
260
+ isInitialized = true;
231
261
  }
232
262
  });
233
263
 
264
+ $effect(() => {
265
+ const nextId = context?.id;
266
+ if (!isInitialized || !nextId) return;
267
+ if (nextId === lastLoadedContextId) return;
268
+ untrack(() => {
269
+ const prevConv = currentConversationId;
270
+ const prevMsgs = prevConv ? [...(getCurrentMessages(prevConv) || [])] : [];
271
+ const prevUserWithImage = [...prevMsgs].reverse().find(
272
+ (m: any) => m.messageType === 'user' && Array.isArray(m.attachments) && m.attachments.length > 0,
273
+ );
274
+ void initializeForContext(nextId).then(() => {
275
+ if (!prevUserWithImage?.attachments?.length) return;
276
+ const nextMsgs = getCurrentMessages(currentConversationId) || [];
277
+ const nextUser = [...nextMsgs].reverse().find((m: any) => m.messageType === 'user');
278
+ if (!nextUser) return;
279
+ if (nextUser.attachments && nextUser.attachments.length > 0) return;
280
+ updateLastUserMessageAttachments(prevUserWithImage.attachments);
281
+ chatLog('[Chat] Restored image attachments after widget id migrate:', prevUserWithImage.attachments.length);
282
+ });
283
+ });
284
+ });
285
+
234
286
  // Methods
235
287
  async function loadConversations(): Promise<any[] | null> {
236
288
  try {
@@ -335,15 +387,28 @@
335
387
  // Persist user message to backend and update local ID with DB UUID
336
388
  if (onSaveMessage) {
337
389
  try {
390
+ const persistAttachments = (submittedAttachments || []).map((att: any, i: number) => ({
391
+ url: att.uploadedUrl || userMessage.attachments?.[i]?.url,
392
+ file: att.file,
393
+ file_type: att.file?.type || userMessage.attachments?.[i]?.file_type,
394
+ file_name: att.file?.name || userMessage.attachments?.[i]?.file_name,
395
+ file_size: att.file?.size || userMessage.attachments?.[i]?.file_size,
396
+ display_order: i + 1,
397
+ })).filter((att: { url?: string; file?: File }) => !!att.url || att.file instanceof File);
338
398
  const saved = await onSaveMessage(
339
399
  currentConversationId,
340
400
  submittedContent,
341
401
  'user',
342
- userMessage.attachments,
402
+ persistAttachments.length > 0 ? persistAttachments : userMessage.attachments,
343
403
  );
344
404
  if (saved?.id && saved.id !== userMessage.id) {
345
405
  chatStore.actions.updateMessageId(currentConversationId, userMessage.id, saved.id);
346
406
  }
407
+ const savedAttachments = saved?.attachments || saved?.data?.attachments;
408
+ if (Array.isArray(savedAttachments) && savedAttachments.length > 0) {
409
+ const targetId = saved?.id || userMessage.id;
410
+ chatStore.actions.updateMessageAttachments(currentConversationId, targetId, savedAttachments);
411
+ }
347
412
  } catch (err) {
348
413
  chatWarn('[Chat] Failed to persist user message:', err);
349
414
  }
@@ -352,7 +417,7 @@
352
417
  if (onSendMessage) {
353
418
  const outgoing = (submittedAttachments || []).map((att) => ({
354
419
  file: att.file,
355
- url: att.uploadedUrl,
420
+ url: att.uploadedUrl || att.preview,
356
421
  uploadId: att.uploadId,
357
422
  fileType: att.file.type,
358
423
  fileName: att.file.name,
@@ -546,11 +611,7 @@
546
611
  }
547
612
 
548
613
  function isCdnUploadableFile(file: File): boolean {
549
- return (
550
- file.type.startsWith('image/') ||
551
- file.type.startsWith('video/') ||
552
- file.type === 'application/pdf'
553
- );
614
+ return !!file && file.size > 0;
554
615
  }
555
616
 
556
617
  function bumpAttachmentProgress(attachmentIndex: number, percentage: number) {
@@ -566,7 +627,7 @@
566
627
  options: { source?: string } = {},
567
628
  ): Promise<void> {
568
629
  if (!onUploadChatFile || !isCdnUploadableFile(file)) {
569
- if (file.type.startsWith('image/')) {
630
+ if (file.type.startsWith('image/') || file.type.startsWith('video/') || file.type === 'application/pdf') {
570
631
  entry.preview = URL.createObjectURL(file);
571
632
  }
572
633
  return;
@@ -580,7 +641,7 @@
580
641
  });
581
642
  entry.uploadedUrl = uploaded.url;
582
643
  entry.uploadId = uploaded.id;
583
- if (file.type.startsWith('image/')) {
644
+ if (file.type.startsWith('image/') || file.type.startsWith('video/') || file.type === 'application/pdf') {
584
645
  entry.preview = uploaded.url;
585
646
  }
586
647
  entry.uploadProgress = 100;
@@ -590,7 +651,7 @@
590
651
  chatLog('[Chat] Attachment uploaded to CDN:', uploaded.url.slice(0, 80), 'id:', uploaded.id);
591
652
  } catch (uploadError) {
592
653
  console.error('[Chat] Upload failed, using local preview where possible:', uploadError);
593
- if (file.type.startsWith('image/')) {
654
+ if (file.type.startsWith('image/') || file.type.startsWith('video/') || file.type === 'application/pdf') {
594
655
  entry.preview = URL.createObjectURL(file);
595
656
  }
596
657
  entry.uploadProgress = 0;
@@ -608,7 +669,7 @@
608
669
  const fileInput = document.createElement('input');
609
670
  fileInput.type = 'file';
610
671
  fileInput.multiple = true;
611
- fileInput.accept = 'image/*,video/*,.pdf,.txt,.md,.mermaid';
672
+ fileInput.accept = '*/*';
612
673
 
613
674
  fileInput.onchange = async (event) => {
614
675
  const files = Array.from((event.target as HTMLInputElement).files || []);
@@ -812,8 +873,9 @@
812
873
 
813
874
  function getAttachmentDataUrls(): string[] {
814
875
  return localAttachments
815
- .filter((att) => att.type === 'image' && (att.preview || att.file.type.startsWith('image/')))
816
- .map((att) => att.preview || '');
876
+ .filter((att) => att.type === 'image' && (att.uploadedUrl || att.preview || att.file?.type?.startsWith('image/')))
877
+ .map((att) => att.uploadedUrl || att.preview || '')
878
+ .filter((url) => !!url);
817
879
  }
818
880
 
819
881
  function getAttachmentUploadIds(): string[] {
@@ -851,12 +913,18 @@
851
913
  return /^Updated \d+ files?/i.test(trimmed) && /\n\nCommit\s+[a-f0-9]{7,40}/i.test(trimmed);
852
914
  }
853
915
 
854
- /** Widget HEAD applies only to codegen completion lines, not preview-rebuild status. */
916
+ /** Widget HEAD: codegen completion, saved-code summaries, or visible preview-recompiled bubbles. */
855
917
  function messageHasWidgetCommit(content?: string | null): boolean {
856
918
  const trimmed = (content || '').trim();
857
- if (!trimmed || isPreviewRecompiledMessage(trimmed)) return false;
858
- if (isCodegenFailureMessage(trimmed)) return false;
859
- if (/^Code generation completed/i.test(trimmed) && CODEGEN_COMMIT_REGEX.test(trimmed)) return true;
919
+ if (!trimmed || isCodegenFailureMessage(trimmed)) return false;
920
+ if (isPreviewRecompiledMessage(trimmed) && CODEGEN_COMMIT_REGEX.test(trimmed)) return true;
921
+ if (CODEGEN_COMMIT_REGEX.test(trimmed) && (
922
+ /^Code generation completed/i.test(trimmed) ||
923
+ /^Code saved/i.test(trimmed) ||
924
+ isBackendCodegenSummary(trimmed)
925
+ )) {
926
+ return true;
927
+ }
860
928
  return isBackendCodegenSummary(trimmed);
861
929
  }
862
930
 
@@ -896,7 +964,12 @@
896
964
 
897
965
  function isCodegenFailureMessage(content?: string | null): boolean {
898
966
  const t = (content || '').trim();
899
- return /^Code generation failed/i.test(t);
967
+ return (
968
+ /^Code generation failed/i.test(t) ||
969
+ /^⚠️/.test(t) ||
970
+ /Too many generation requests/i.test(t) ||
971
+ /temporarily unavailable/i.test(t)
972
+ );
900
973
  }
901
974
 
902
975
  function markWidgetHeadAtLatestCommit(preferredMessageId?: string) {
@@ -981,13 +1054,40 @@
981
1054
  }
982
1055
  }
983
1056
 
1057
+ function updateLastUserMessageAttachments(
1058
+ attachments: Array<{ id?: string; url: string; file_type?: string; file_name?: string | null }>,
1059
+ ) {
1060
+ if (!currentConversationId || !attachments?.length) return;
1061
+ const msgs = getCurrentMessages(currentConversationId);
1062
+ const idx = getLastUserMessageIndex(msgs);
1063
+ if (idx < 0) return;
1064
+ const mapped = attachments.map((a, i) => ({
1065
+ id: a.id || `att_${Date.now()}_${i}`,
1066
+ url: a.url,
1067
+ file_type: a.file_type || 'application/octet-stream',
1068
+ file_name: a.file_name || 'attachment',
1069
+ file_size: null,
1070
+ display_order: i + 1,
1071
+ created_at: new Date().toISOString(),
1072
+ }));
1073
+ chatStore.actions.updateMessageAttachments(currentConversationId, msgs[idx].id, mapped);
1074
+ chatLog('[Chat] Patched last user message attachments:', mapped.length);
1075
+ }
1076
+
984
1077
  /** Reload messages from backend and re-mark widget HEAD (picks up server-persisted summaries). */
985
1078
  async function reloadConversationMessages() {
986
1079
  if (!currentConversationId || !onLoadMessages) return;
987
1080
  try {
1081
+ const localMsgs = getCurrentMessages(currentConversationId);
1082
+ const localHasHead = localMsgs.some((m) => m.isWidgetHead || messageHasWidgetCommit(m.messageContent));
988
1083
  const loaded = await onLoadMessages(currentConversationId);
1084
+ const loadedHasHead = (loaded || []).some((m) => messageHasWidgetCommit(m.messageContent));
989
1085
  if (loaded?.length) {
990
- chatStore.actions.setMessages(currentConversationId, loaded);
1086
+ if (localHasHead && !loadedHasHead) {
1087
+ chatLog('[Chat] Keep local HEAD commit message — DB reload has no commit yet');
1088
+ } else {
1089
+ chatStore.actions.setMessages(currentConversationId, loaded);
1090
+ }
991
1091
  }
992
1092
  markWidgetHeadAtLatestCommit();
993
1093
  chatLog('[Chat] Reloaded conversation messages:', loaded?.length ?? 0);
@@ -1035,6 +1135,10 @@
1035
1135
  markWidgetHeadAtLatestCommit,
1036
1136
  updateLatestCodegenStatusMessage,
1037
1137
  reloadConversationMessages,
1138
+ updateLastUserMessageAttachments,
1139
+ selectConversation: async (conversationId: string) => {
1140
+ await loadConversation(conversationId);
1141
+ },
1038
1142
  };
1039
1143
  onChatReady(chatMethods);
1040
1144
  });
@@ -1086,29 +1190,38 @@
1086
1190
 
1087
1191
  function handleMessagesResizeStart(e: PointerEvent) {
1088
1192
  e.preventDefault();
1089
- isResizingMessages = true;
1193
+ e.stopPropagation();
1090
1194
  const handle = e.currentTarget as HTMLElement;
1091
- const messagesEl = handle.previousElementSibling as HTMLElement | null;
1092
- if (!messagesEl) return;
1195
+ try {
1196
+ handle.setPointerCapture?.(e.pointerId);
1197
+ } catch {
1198
+ /* pointer capture is best-effort */
1199
+ }
1200
+ isResizingMessages = true;
1201
+ const messagesEl =
1202
+ (handle.previousElementSibling as HTMLElement | null) ||
1203
+ (chatContainer?.querySelector('.prompt-chat-messages-area') as HTMLElement | null);
1093
1204
  const startY = e.clientY;
1094
- const startHeight = messagesEl.getBoundingClientRect().height;
1205
+ const startHeight = messagesEl?.getBoundingClientRect().height ?? MIN_MESSAGES_HEIGHT;
1095
1206
  let lastDy = 0;
1096
1207
 
1097
1208
  const onMove = (ev: PointerEvent) => {
1098
1209
  const dy = ev.clientY - startY;
1099
1210
  if (resizeExpandsContainer) {
1100
- // Report the incremental delta to the parent so it can enlarge the outer chat
1101
- // container. The messages area stays flex:1 within the new container height.
1102
1211
  const frameDelta = dy - lastDy;
1103
1212
  lastDy = dy;
1104
1213
  if (frameDelta !== 0) onMessagesResize?.(frameDelta);
1105
1214
  } else {
1106
- const next = Math.max(MIN_MESSAGES_HEIGHT, startHeight + dy);
1107
- messagesAreaHeight = next;
1215
+ messagesAreaHeight = Math.max(MIN_MESSAGES_HEIGHT, startHeight + dy);
1108
1216
  }
1109
1217
  };
1110
1218
  const onUp = () => {
1111
1219
  isResizingMessages = false;
1220
+ try {
1221
+ handle.releasePointerCapture?.(e.pointerId);
1222
+ } catch {
1223
+ /* already released */
1224
+ }
1112
1225
  window.removeEventListener('pointermove', onMove);
1113
1226
  window.removeEventListener('pointerup', onUp);
1114
1227
  };
@@ -1119,7 +1232,7 @@
1119
1232
 
1120
1233
  <div
1121
1234
  bind:this={chatContainer}
1122
- class="{className ? className : 'prompt-chat-ct'} relative flex flex-col overflow-hidden bg-white border border-gray-300 rounded-medium h-full"
1235
+ class="{className ? className : 'prompt-chat-ct'} relative flex flex-col overflow-hidden bg-white border border-gray-300 rounded-medium {resizeExpandsContainer ? 'shrink-0' : 'h-full'}"
1123
1236
  style={style}
1124
1237
  >
1125
1238
  {#if showHeader}
@@ -1155,7 +1268,7 @@
1155
1268
  <!-- Chat Interface -->
1156
1269
  {#if isInitialized}
1157
1270
  <div
1158
- class="prompt-chat-messages-area overflow-hidden"
1271
+ class="prompt-chat-messages-area flex min-h-0 flex-col overflow-hidden"
1159
1272
  style="{messagesAreaHeight !== null
1160
1273
  ? `flex: 0 0 ${messagesAreaHeight}px; height: ${messagesAreaHeight}px;`
1161
1274
  : 'flex: 1 1 auto; min-height: 0;'}"
@@ -1197,7 +1310,7 @@
1197
1310
  showScreenshot={config?.features?.screenshot !== false}
1198
1311
  showRecord={config?.features?.screenRecording !== false}
1199
1312
  showUpload={config?.features?.fileUpload !== false}
1200
- showComplete={config?.features?.restoreCheckpoint !== false}
1313
+ showComplete={config?.features?.markComplete === true}
1201
1314
  on:screenshot={handleScreenshot}
1202
1315
  on:recordStart={handleRecordStart}
1203
1316
  on:recordStop={handleRecordStop}
@@ -1238,14 +1351,25 @@
1238
1351
 
1239
1352
  <!-- Error Display -->
1240
1353
  {#if ui?.errorMessage}
1241
- <div class="error-message-ct absolute top-0 left-0 right-0 bg-red-50 border-l-4 border-red-400 p-3 flex items-center justify-between text-sm text-red-700 rounded-none z-50">
1242
- <span>{ui.errorMessage}</span>
1243
- <button
1244
- class="ml-2 text-red-400 hover:text-red-600 font-semibold w-5 h-5 flex items-center justify-center"
1245
- onclick={() => chatStore.actions.updateUI({ errorMessage: null })}
1246
- >
1247
-
1248
- </button>
1354
+ <div class="error-message-ct absolute top-0 left-0 right-0 bg-red-50 border-l-4 border-red-400 p-3 flex items-center justify-between gap-2 text-sm text-red-700 rounded-none z-10">
1355
+ <span class="error-message-text min-w-0 flex-1">{ui.errorMessage}</span>
1356
+ <div class="error-message-actions flex items-center gap-2 shrink-0">
1357
+ {#if ui.errorMessage.startsWith('Could not load chat history') || ui.errorMessage.startsWith('Failed to load')}
1358
+ <button
1359
+ type="button"
1360
+ class="retry-load-history-btn px-2 py-1 text-xs font-semibold rounded border border-red-300 bg-white text-red-700 hover:bg-red-100"
1361
+ onclick={() => void retryLoadHistory()}
1362
+ >
1363
+ Retry load history
1364
+ </button>
1365
+ {/if}
1366
+ <button
1367
+ class="ml-0 text-red-400 hover:text-red-600 font-semibold w-5 h-5 flex items-center justify-center"
1368
+ onclick={() => chatStore.actions.updateUI({ errorMessage: null })}
1369
+ >
1370
+
1371
+ </button>
1372
+ </div>
1249
1373
  </div>
1250
1374
  {/if}
1251
1375
  </div>
@@ -33,6 +33,15 @@ interface ChatMethods {
33
33
  updateLatestCodegenStatusMessage: (content: string) => void;
34
34
  /** Reload messages from backend and refresh widget HEAD marker. */
35
35
  reloadConversationMessages: () => Promise<void>;
36
+ /** Select an existing conversation (loads its messages into the chat). */
37
+ selectConversation: (conversationId: string) => Promise<void>;
38
+ /** Replace attachments on the latest user message (CDN URLs after persist). */
39
+ updateLastUserMessageAttachments: (attachments: Array<{
40
+ id?: string;
41
+ url: string;
42
+ file_type?: string;
43
+ file_name?: string | null;
44
+ }>) => void;
36
45
  }
37
46
  interface Props {
38
47
  context: ChatContext;
@@ -60,6 +69,7 @@ interface Props {
60
69
  onCreateConversation?: (contextId: string, title: string) => Promise<any>;
61
70
  onSaveMessage?: (conversationId: string, content: string, messageType: string, attachments?: Array<{
62
71
  url?: string;
72
+ file?: File;
63
73
  file_type?: string;
64
74
  file_name?: string | null;
65
75
  file_size?: number | null;
@@ -104,8 +114,8 @@ interface Props {
104
114
  * internal messages area grows/shrinks within the current chat height.
105
115
  */
106
116
  resizeExpandsContainer?: boolean;
107
- /** Delta in pixels for the current drag frame (positive = grew downward). */
108
- onMessagesResize?: (deltaPx: number) => void;
117
+ /** Delta in pixels, plus the resulting chat container height (for parent-owned resize). */
118
+ onMessagesResize?: (deltaPx: number, nextContainerHeightPx?: number) => void;
109
119
  }
110
120
  declare const Chat: import("svelte").Component<Props, {}, "">;
111
121
  type Chat = ReturnType<typeof Chat>;
@@ -76,11 +76,14 @@
76
76
  return /^Updated \d+ files?/i.test(trimmed) && /\n\nCommit\s+[a-f0-9]{7,40}/i.test(trimmed);
77
77
  }
78
78
 
79
- /** Codegen completion with commit not preview-rebuild status lines. */
79
+ /** Codegen completion or preview-recompiled bubble that carries a commit hash. */
80
80
  function messageHasCommit(message: ChatMessage): boolean {
81
- if (!message.messageContent || isPreviewRecompiledMessage(message)) return false;
81
+ if (!message.messageContent) return false;
82
82
  const trimmed = message.messageContent.trim();
83
83
  if (isCodegenFailure(message)) return false;
84
+ if (isPreviewRecompiledMessage(message) && /commit:?\s+[a-f0-9]{7,40}/i.test(trimmed)) {
85
+ return true;
86
+ }
84
87
  if (/^Code generation completed/i.test(trimmed) && /Commit:?\s+[a-f0-9]{7,40}/i.test(trimmed)) {
85
88
  return true;
86
89
  }
@@ -152,9 +155,15 @@
152
155
  }
153
156
 
154
157
  function isCodegenFailure(message: ChatMessage): boolean {
155
- return message.messageType === 'assistant'
156
- && !!message.messageContent
157
- && /^Code generation failed/i.test(message.messageContent.trim());
158
+ if (message.messageType !== 'assistant' || !message.messageContent) return false;
159
+ const trimmed = message.messageContent.trim();
160
+ return (
161
+ /^Code generation failed/i.test(trimmed) ||
162
+ /^⚠️/.test(trimmed) ||
163
+ /Too many generation requests/i.test(trimmed) ||
164
+ /temporarily unavailable/i.test(trimmed) ||
165
+ /Generation Failed/i.test(trimmed)
166
+ );
158
167
  }
159
168
 
160
169
  function canRestoreToMessage(message: ChatMessage): boolean {
@@ -197,6 +206,10 @@
197
206
  * Previously this was gated to Debug OFF (assistant reply hidden), but we want the buttons
198
207
  * available in Debug ON too — the assistant summary and the user-side chrome coexist. */
199
208
  function showCommitMetaOnUserTurn(turn: ChatTurn): boolean {
209
+ const visibleHead = turn.replies.some(
210
+ (r) => r.isWidgetHead && shouldShowAssistantReply(r) && messageHasCommit(r),
211
+ );
212
+ if (visibleHead) return false;
200
213
  return !!getTurnCommitReply(turn);
201
214
  }
202
215
 
@@ -225,8 +238,18 @@
225
238
  chatTurns = rebalancePreviewRecompiledAcrossTurns(turns);
226
239
  });
227
240
 
228
- function messageImpliesAttachedImage(content: string): boolean {
229
- return /imaginea ata[sș]at[ăa]|attached (reference )?image|reference image|din imagine/i.test(content);
241
+ function messageImpliesAttachedFile(content: string): boolean {
242
+ return /imaginea ata[sș]at[ăa]|attached (reference )?(image|document|file|pdf|video)|reference image|din imagine|ata[sș]at[ăa]?\s+(document|pdf|fi[sș]ier|video)|attached document/i.test(
243
+ content,
244
+ );
245
+ }
246
+
247
+ function turnNeedsRetry(turn: ChatTurn): boolean {
248
+ if (turnHasCodegenFailure(turn)) return true;
249
+ const userMsg = turn.user;
250
+ if (!userMsg?.messageContent) return false;
251
+ const noAttachments = !userMsg.attachments || userMsg.attachments.length === 0;
252
+ return noAttachments && messageImpliesAttachedFile(userMsg.messageContent) && !getTurnCommitReply(turn);
230
253
  }
231
254
 
232
255
  let visibleMessages = $derived(
@@ -296,8 +319,23 @@
296
319
  }
297
320
  }
298
321
 
299
- function handleRetryTurn(turn: ChatTurn) {
300
- const retryContent = turn.user?.messageContent?.trim();
322
+ function getRetryPromptForTurn(turn: ChatTurn, turnIndex: number): string | null {
323
+ const own = turn.user?.messageContent?.trim();
324
+ if (own) return own;
325
+ for (let i = turnIndex - 1; i >= 0; i--) {
326
+ const previous = chatTurns[i]?.user?.messageContent?.trim();
327
+ if (previous) return previous;
328
+ }
329
+ return null;
330
+ }
331
+
332
+ function isLastCodegenFailureInTurn(turn: ChatTurn, reply: ChatMessage): boolean {
333
+ const failures = turn.replies.filter(isCodegenFailure);
334
+ return failures.length > 0 && failures[failures.length - 1]?.id === reply.id;
335
+ }
336
+
337
+ function handleRetryTurn(turn: ChatTurn, turnIndex: number) {
338
+ const retryContent = getRetryPromptForTurn(turn, turnIndex);
301
339
  if (!retryContent) return;
302
340
  chatLog('Retry requested for failed codegen turn:', retryContent.slice(0, 80));
303
341
  onRetryMessage?.(retryContent);
@@ -613,18 +651,33 @@
613
651
  <div class="message-attachments-container">
614
652
  <AttachmentDisplay attachments={userMsg.attachments} variant="message" showPreview={true} />
615
653
  </div>
616
- {:else if userMsg.messageContent && messageImpliesAttachedImage(userMsg.messageContent)}
654
+ {:else if userMsg.messageContent && messageImpliesAttachedFile(userMsg.messageContent)}
617
655
  <div class="wc-missing-attachment-placeholder message-attachments-container flex flex-col items-center gap-1 px-3 pb-3">
618
- <div class="wc-missing-attachment-thumb flex h-20 w-28 flex-col items-center justify-center gap-1 rounded-md border border-dashed border-gray-300 bg-gray-100 text-gray-400" title="Image not available" aria-label="Missing image attachment">
656
+ <div class="wc-missing-attachment-thumb flex h-20 w-28 flex-col items-center justify-center gap-1 rounded-md border border-dashed border-gray-300 bg-gray-100 text-gray-400" title="Attachment not available" aria-label="Missing file attachment">
619
657
  <svg class="h-8 w-8 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
620
658
  <rect x="3" y="5" width="18" height="14" rx="2" />
621
659
  <path d="M4 4l16 16" stroke-linecap="round" />
622
660
  </svg>
623
661
  </div>
624
- <p class="wc-missing-attachment-caption max-w-[12rem] text-center text-xs text-gray-500">Image not saved in chat history. Re-attach with Upload before Send.</p>
662
+ <p class="wc-missing-attachment-caption max-w-[12rem] text-center text-xs text-gray-500">Attachment not saved. Re-attach with Upload, then Retry.</p>
625
663
  </div>
626
664
  {/if}
627
665
  </div>
666
+ {#if turnNeedsRetry(turn) && !isLoading}
667
+ <div class="retry-codegen-row flex justify-end mt-1">
668
+ <button
669
+ class="retry-codegen-btn"
670
+ onclick={() => handleRetryTurn(turn, turnIndex)}
671
+ title="Retry this prompt (same as Retry on the error below)"
672
+ >
673
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
674
+ <path d="M21 12a9 9 0 1 1-2.64-6.36"/>
675
+ <path d="M21 3v6h-6"/>
676
+ </svg>
677
+ Retry
678
+ </button>
679
+ </div>
680
+ {/if}
628
681
  </div>
629
682
 
630
683
  {@const commitReply = getTurnCommitReply(turn)}
@@ -709,12 +762,12 @@
709
762
  </div>
710
763
  {/if}
711
764
 
712
- {#if isCodegenFailure(reply) && turn.user && !isLoading}
765
+ {#if isCodegenFailure(reply) && isLastCodegenFailureInTurn(turn, reply) && getRetryPromptForTurn(turn, turnIndex) && !isLoading}
713
766
  <div class="retry-codegen-row flex justify-start mt-1">
714
767
  <button
715
768
  class="retry-codegen-btn"
716
- onclick={() => handleRetryTurn(turn)}
717
- title="Retry this code generation prompt"
769
+ onclick={() => handleRetryTurn(turn, turnIndex)}
770
+ title="Retry the last user prompt (same generation request)"
718
771
  >
719
772
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
720
773
  <path d="M21 12a9 9 0 1 1-2.64-6.36"/>