@widgetic/chat 0.1.4

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 (72) hide show
  1. package/README.md +440 -0
  2. package/dist/adapters/index.d.ts +2 -0
  3. package/dist/adapters/index.js +2 -0
  4. package/dist/adapters/widgeticAdapter.d.ts +185 -0
  5. package/dist/adapters/widgeticAdapter.js +766 -0
  6. package/dist/components/ActionBar.svelte +342 -0
  7. package/dist/components/ActionBar.svelte.d.ts +37 -0
  8. package/dist/components/AttachmentDisplay.svelte +547 -0
  9. package/dist/components/AttachmentDisplay.svelte.d.ts +12 -0
  10. package/dist/components/Chat.svelte +1253 -0
  11. package/dist/components/Chat.svelte.d.ts +112 -0
  12. package/dist/components/ChatHeader.svelte +182 -0
  13. package/dist/components/ChatHeader.svelte.d.ts +12 -0
  14. package/dist/components/ChatInput.svelte +290 -0
  15. package/dist/components/ChatInput.svelte.d.ts +15 -0
  16. package/dist/components/ChatMessages.svelte +996 -0
  17. package/dist/components/ChatMessages.svelte.d.ts +28 -0
  18. package/dist/components/CodeBlock.svelte +286 -0
  19. package/dist/components/CodeBlock.svelte.d.ts +10 -0
  20. package/dist/components/ContextPreview.svelte +66 -0
  21. package/dist/components/ContextPreview.svelte.d.ts +8 -0
  22. package/dist/components/LoadingIndicator.svelte +151 -0
  23. package/dist/components/LoadingIndicator.svelte.d.ts +9 -0
  24. package/dist/components/StatusIndicator.svelte +116 -0
  25. package/dist/components/StatusIndicator.svelte.d.ts +9 -0
  26. package/dist/components/SuggestionButtons.svelte +244 -0
  27. package/dist/components/SuggestionButtons.svelte.d.ts +26 -0
  28. package/dist/components/index.d.ts +12 -0
  29. package/dist/components/index.js +12 -0
  30. package/dist/config.d.ts +43 -0
  31. package/dist/config.js +67 -0
  32. package/dist/constants/colors.d.ts +20 -0
  33. package/dist/constants/colors.js +16 -0
  34. package/dist/index.d.ts +11 -0
  35. package/dist/index.js +20 -0
  36. package/dist/services/chatService.d.ts +72 -0
  37. package/dist/services/chatService.js +355 -0
  38. package/dist/services/index.d.ts +2 -0
  39. package/dist/services/index.js +2 -0
  40. package/dist/stores/chatStore.d.ts +46 -0
  41. package/dist/stores/chatStore.js +219 -0
  42. package/dist/stores/index.d.ts +2 -0
  43. package/dist/stores/index.js +2 -0
  44. package/dist/types/adapter.d.ts +86 -0
  45. package/dist/types/adapter.js +1 -0
  46. package/dist/types/api-temp.d.ts +61 -0
  47. package/dist/types/api-temp.js +42 -0
  48. package/dist/types/attachment.d.ts +84 -0
  49. package/dist/types/attachment.js +33 -0
  50. package/dist/types/chat.d.ts +114 -0
  51. package/dist/types/chat.js +1 -0
  52. package/dist/types/config.d.ts +89 -0
  53. package/dist/types/config.js +63 -0
  54. package/dist/types/context.d.ts +108 -0
  55. package/dist/types/context.js +11 -0
  56. package/dist/types/conversation.d.ts +45 -0
  57. package/dist/types/conversation.js +1 -0
  58. package/dist/types/events.d.ts +141 -0
  59. package/dist/types/events.js +1 -0
  60. package/dist/types/index.d.ts +16 -0
  61. package/dist/types/index.js +10 -0
  62. package/dist/types/message.d.ts +82 -0
  63. package/dist/types/message.js +1 -0
  64. package/dist/types/state.d.ts +117 -0
  65. package/dist/types/state.js +1 -0
  66. package/dist/utils/fileUtils.d.ts +93 -0
  67. package/dist/utils/fileUtils.js +299 -0
  68. package/dist/utils/index.d.ts +3 -0
  69. package/dist/utils/index.js +4 -0
  70. package/dist/utils/logger.d.ts +4 -0
  71. package/dist/utils/logger.js +28 -0
  72. package/package.json +104 -0
@@ -0,0 +1,1253 @@
1
+ <script lang="ts">
2
+ import { onMount, onDestroy } from 'svelte';
3
+ import { chatStore, getCurrentMessages } from '../stores/chatStore.js';
4
+ import { createChatService } from '../services/chatService.js';
5
+ import { setLogsEnabled, chatLog, chatWarn } from '../utils/logger.js';
6
+ import type { ChatConfig, ChatContext } from '../types/index.js';
7
+ import type { AttachmentInput } from '../types/attachment.js';
8
+ import ChatMessages from './ChatMessages.svelte';
9
+ import ChatInput from './ChatInput.svelte';
10
+ import ActionBar from './ActionBar.svelte';
11
+ import StatusIndicator from './StatusIndicator.svelte';
12
+
13
+ // Props
14
+ interface Props {
15
+ context: ChatContext;
16
+ config?: Partial<ChatConfig>;
17
+ class?: string;
18
+ /** Inline style passed to the root chat container (e.g. `height: 600px`). */
19
+ style?: string;
20
+ isCompleted?: boolean;
21
+ onStatsUpdate?: (messageCount: number, firstMessage: string | null) => void;
22
+ onChatReady?: (chatMethods: ChatMethods) => void;
23
+ onCompletionToggle?: () => void;
24
+ /** When provided, called instead of the built-in mock service for sending messages.
25
+ * The parent handles the actual API call and can push assistant messages via addAssistantMessage. */
26
+ onSendMessage?: (
27
+ content: string,
28
+ attachments?: Array<{
29
+ file?: File;
30
+ url?: string;
31
+ uploadId?: string;
32
+ fileType: string;
33
+ fileName: string;
34
+ fileSize?: number;
35
+ }>,
36
+ ) => Promise<void>;
37
+ /** Backend integration callbacks — when provided, replace mock chatService methods */
38
+ onLoadConversations?: (contextId: string) => Promise<any[] | null>;
39
+ onLoadMessages?: (conversationId: string) => Promise<any[]>;
40
+ onCreateConversation?: (contextId: string, title: string) => Promise<any>;
41
+ onSaveMessage?: (
42
+ conversationId: string,
43
+ content: string,
44
+ messageType: string,
45
+ attachments?: Array<{ url?: string; file_type?: string; file_name?: string | null; file_size?: number | null; display_order?: number }>,
46
+ ) => Promise<any>;
47
+ onRestoreCheckpointBackend?: (conversationId: string, checkpointMessageId: string) => Promise<any>;
48
+ onTakeScreenshot?: () => Promise<string | null>;
49
+ /** Fires whenever the active conversation ID changes (including initial null → uuid). */
50
+ onConversationChange?: (conversationId: string | null) => void;
51
+ /** Fires when unsent draft text or pending attachments change (for parent localStorage sync). */
52
+ onDraftChange?: () => void;
53
+ /**
54
+ * When provided, Retry on a failed codegen turn calls this instead of sendMessage,
55
+ * so the host can re-run codegen without duplicating the user bubble.
56
+ */
57
+ onRetryMessage?: (content: string) => Promise<void>;
58
+ /**
59
+ * When provided, attachments are uploaded immediately and stored as CDN URLs
60
+ * instead of large data URLs in chat history. Host should use TUS for files >10MB.
61
+ */
62
+ onUploadChatFile?: (
63
+ file: File,
64
+ options?: { onProgress?: (percentage: number) => void; source?: string },
65
+ ) => Promise<{ id: string; url: string; fileType: string; fileName: string; fileSize: number }>;
66
+ showHeader?: boolean;
67
+ /** Custom title displayed in the header. If empty/undefined, defaults to context.title or context.id. */
68
+ headerTitle?: string;
69
+ /** When false, hides the icon + type description row below the title, making the header compact. */
70
+ showDescriptionHeader?: boolean;
71
+ /** When false, suppresses all console.log/warn output from @widgetic/chat. Errors still show. */
72
+ showLogs?: boolean;
73
+ /**
74
+ * When true, dragging the resize handle between messages and the action bar
75
+ * emits an `onMessagesResize` delta so the parent can grow the chat container
76
+ * itself (not just the internal messages area). When false (default), only the
77
+ * internal messages area grows/shrinks within the current chat height.
78
+ */
79
+ resizeExpandsContainer?: boolean;
80
+ /** Delta in pixels for the current drag frame (positive = grew downward). */
81
+ onMessagesResize?: (deltaPx: number) => void;
82
+ }
83
+
84
+ // Expose methods to parent component
85
+ interface ChatMethods {
86
+ sendMessage: (content: string, attachments?: File[]) => Promise<void>;
87
+ addAssistantMessage: (content: string, skipPersist?: boolean) => void;
88
+ addUserMessage: (content: string, attachments?: Array<{ id: string; url: string; file_type: string; file_name: string; file_size?: number }>) => void;
89
+ /** Returns the current conversation ID (null if no conversation is active). */
90
+ getConversationId: () => string | null;
91
+ /** Add an image attachment (data URL) to the input area for preview before sending. */
92
+ addImageAttachment: (dataUrl: string, fileName?: string) => void;
93
+ /** Get unsent draft text from the chat input. */
94
+ getDraftText: () => string;
95
+ /** Restore unsent draft text into the chat input. */
96
+ setDraftText: (text: string) => void;
97
+ /** Get attachment preview data URLs in current order. */
98
+ getAttachmentDataUrls: () => string[];
99
+ /** Get CDN upload row IDs for image attachments (codegen attachmentIds). */
100
+ getAttachmentUploadIds: () => string[];
101
+ /** Replace chat attachments from stored data URLs (restores draft attachments). */
102
+ restoreAttachmentsFromDataUrls: (dataUrls: string[]) => void;
103
+ /** Clear draft text and pending attachments. */
104
+ clearDraft: () => void;
105
+ /** Remove stale "Preview build failed" suffix from the latest assistant summary after a successful rebuild. */
106
+ clearStalePreviewBuildFailures: () => void;
107
+ /** Mark the latest assistant message that contains a commit as widget HEAD. */
108
+ markWidgetHeadAtLatestCommit: () => void;
109
+ /** Update text on the most recent codegen assistant status message. */
110
+ updateLatestCodegenStatusMessage: (content: string) => void;
111
+ /** Reload messages from backend and refresh widget HEAD marker. */
112
+ reloadConversationMessages: () => Promise<void>;
113
+ }
114
+
115
+ 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();
116
+
117
+ function notifyDraftChange() {
118
+ onDraftChange?.();
119
+ }
120
+
121
+ // Apply log suppression immediately
122
+ $effect(() => {
123
+ setLogsEnabled(showLogs);
124
+ });
125
+
126
+ // Local state
127
+ let chatContainer: HTMLElement;
128
+ let isInitialized = $state(false);
129
+ let chatService = $state(createChatService());
130
+ let currentConversationId = $state<string | null>(null);
131
+
132
+ // Local input state per chat instance (instead of shared store state)
133
+ let localInputText = $state('');
134
+ let localAttachments = $state<AttachmentInput[]>([]);
135
+ let localIsRecording = $state(false);
136
+ let localIsSending = $state(false);
137
+ let localIsUploading = $state(false);
138
+
139
+ // Reactive state from store - using proper store subscription
140
+ let chatState = $state<any>(null);
141
+ let storeUnsubscribe: (() => void) | null = null;
142
+
143
+ // Initialize store subscription
144
+ onMount(() => {
145
+ storeUnsubscribe = chatStore.subscribe(state => {
146
+ chatState = state;
147
+ });
148
+ });
149
+
150
+ onDestroy(() => {
151
+ storeUnsubscribe?.();
152
+ });
153
+
154
+ // Derived values from chat state
155
+ let activeContext = $derived(chatState?.activeContext);
156
+ let ui = $derived(chatState?.ui);
157
+ let messages = $derived(chatState?.messages);
158
+ let conversations = $derived(chatState?.conversations);
159
+
160
+ // Computed values
161
+ let isLoading = $derived(ui?.isLoading || false);
162
+ let isSendingOrLoading = $derived(isLoading || localIsSending);
163
+ let currentMessages = $derived(
164
+ currentConversationId && messages ? messages.byConversationId[currentConversationId] || [] : []
165
+ );
166
+ let availableConversations = $derived(
167
+ context && conversations ? conversations.byContextId[context.id] || [] : []
168
+ );
169
+
170
+ /** Prefer a conversation that already has messages (avoids empty auto-created convos). */
171
+ async function activateBestConversation(conversations: any[]): Promise<void> {
172
+ for (const conversation of conversations) {
173
+ const loadedMessages = onLoadMessages
174
+ ? await onLoadMessages(conversation.id)
175
+ : await chatService.getMessages(conversation.id);
176
+ const messageList = loadedMessages || [];
177
+ chatStore.actions.setMessages(conversation.id, messageList);
178
+
179
+ if (messageList.length > 0) {
180
+ currentConversationId = conversation.id;
181
+ markWidgetHeadAtLatestCommit();
182
+ chatLog('[Chat] Restored conversation with history:', conversation.id, messageList.length, 'messages');
183
+ return;
184
+ }
185
+ }
186
+
187
+ const fallbackConversation = conversations[0];
188
+ chatLog('[Chat] All conversations empty — using latest:', fallbackConversation.id);
189
+ currentConversationId = fallbackConversation.id;
190
+ }
191
+
192
+ // Lifecycle
193
+ onMount(async () => {
194
+ 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
+
224
+ isInitialized = true;
225
+ chatLog('[Chat] Initialized successfully');
226
+ } catch (error) {
227
+ console.error('[Chat] Failed to initialize:', error);
228
+ chatStore.actions.updateUI({
229
+ errorMessage: 'Failed to initialize chat. Please try again.'
230
+ });
231
+ }
232
+ });
233
+
234
+ // Methods
235
+ async function loadConversations(): Promise<any[] | null> {
236
+ try {
237
+ chatStore.actions.updateUI({ isLoading: true });
238
+ const loaded = onLoadConversations
239
+ ? await onLoadConversations(context.id)
240
+ : await chatService.getConversations(context.id);
241
+ if (loaded === null) {
242
+ chatWarn('[Chat] Conversation load unavailable (client not ready or API error)');
243
+ return null;
244
+ }
245
+ chatStore.actions.setConversations(context.id, loaded);
246
+ return loaded;
247
+ } catch (error) {
248
+ console.error('Failed to load conversations:', error);
249
+ chatStore.actions.updateUI({
250
+ errorMessage: 'Failed to load conversations.'
251
+ });
252
+ return null;
253
+ } finally {
254
+ chatStore.actions.updateUI({ isLoading: false });
255
+ }
256
+ }
257
+
258
+ async function createNewConversation(title?: string) {
259
+ try {
260
+ const defaultTitle = title || `Chat about ${context.title || context.id}`;
261
+ const conversation = onCreateConversation
262
+ ? await onCreateConversation(context.id, defaultTitle)
263
+ : await chatService.createConversation(context.id, { title: defaultTitle });
264
+
265
+ chatStore.dispatch({
266
+ type: 'ADD_CONVERSATION',
267
+ payload: { contextId: context.id, conversation }
268
+ });
269
+
270
+ currentConversationId = conversation.id;
271
+ return conversation;
272
+ } catch (error) {
273
+ console.error('Failed to create conversation:', error);
274
+ throw error;
275
+ }
276
+ }
277
+
278
+ async function loadConversation(conversationId: string) {
279
+ try {
280
+ currentConversationId = conversationId;
281
+ chatStore.actions.updateUI({ isLoading: true });
282
+
283
+ const messages = onLoadMessages
284
+ ? await onLoadMessages(conversationId)
285
+ : await chatService.getMessages(conversationId);
286
+ chatStore.actions.setMessages(conversationId, messages);
287
+ markWidgetHeadAtLatestCommit();
288
+ } catch (error) {
289
+ console.error('Failed to load conversation:', error);
290
+ chatStore.actions.updateUI({
291
+ errorMessage: 'Failed to load conversation.'
292
+ });
293
+ } finally {
294
+ chatStore.actions.updateUI({ isLoading: false });
295
+ }
296
+ }
297
+
298
+ async function sendMessage(content: string, attachments?: AttachmentInput[]) {
299
+ if (!currentConversationId) {
300
+ console.error('No active conversation');
301
+ return;
302
+ }
303
+
304
+ chatLog('[Chat] Sending message:', content.substring(0, 60));
305
+ const submittedContent = content;
306
+ const submittedAttachments = attachments;
307
+
308
+ try {
309
+ localIsSending = true;
310
+
311
+ // Clear the controlled input immediately. Waiting for persistence/codegen lets
312
+ // image thumbnails linger during long generations and can re-save them as drafts.
313
+ localInputText = '';
314
+ localAttachments = [];
315
+ notifyDraftChange();
316
+
317
+ // Clear head/checkpoint marks — new prompt starts a new turn
318
+ const existingMsgs = getCurrentMessages(currentConversationId);
319
+ if (existingMsgs?.some((m: any) => m.rolledBackAt || m.isWidgetHead)) {
320
+ const cleared = existingMsgs.map((m: any) => ({
321
+ ...m,
322
+ rolledBackAt: null,
323
+ isWidgetHead: false,
324
+ }));
325
+ chatStore.actions.setMessages(currentConversationId, cleared);
326
+ }
327
+
328
+ // Add user message to the UI immediately (local mock for instant feedback)
329
+ const userMessage = await chatService.sendMessage(currentConversationId, {
330
+ messageContent: submittedContent,
331
+ attachments: submittedAttachments
332
+ });
333
+ chatStore.actions.addMessage(currentConversationId, userMessage);
334
+
335
+ // Persist user message to backend and update local ID with DB UUID
336
+ if (onSaveMessage) {
337
+ try {
338
+ const saved = await onSaveMessage(
339
+ currentConversationId,
340
+ submittedContent,
341
+ 'user',
342
+ userMessage.attachments,
343
+ );
344
+ if (saved?.id && saved.id !== userMessage.id) {
345
+ chatStore.actions.updateMessageId(currentConversationId, userMessage.id, saved.id);
346
+ }
347
+ } catch (err) {
348
+ chatWarn('[Chat] Failed to persist user message:', err);
349
+ }
350
+ }
351
+
352
+ if (onSendMessage) {
353
+ const outgoing = (submittedAttachments || []).map((att) => ({
354
+ file: att.file,
355
+ url: att.uploadedUrl,
356
+ uploadId: att.uploadId,
357
+ fileType: att.file.type,
358
+ fileName: att.file.name,
359
+ fileSize: att.file.size,
360
+ }));
361
+ await onSendMessage(submittedContent, outgoing.length > 0 ? outgoing : undefined);
362
+ } else {
363
+ const aiResponse = await chatService.processAIResponse(currentConversationId, userMessage);
364
+ chatStore.actions.addMessage(currentConversationId, aiResponse);
365
+ }
366
+
367
+ } catch (error) {
368
+ console.error('Failed to send message:', error);
369
+ chatStore.actions.updateUI({
370
+ errorMessage: 'Failed to send message. Please try again.'
371
+ });
372
+ } finally {
373
+ localIsSending = false;
374
+ }
375
+ }
376
+
377
+ /** Add an assistant message to the current conversation (called by parent via chatMethods).
378
+ * When skipPersist is true, the message is shown locally but not saved to DB
379
+ * (use when the backend already persists its own version). */
380
+ function addAssistantMessage(content: string, skipPersist = false) {
381
+ if (!currentConversationId) return;
382
+ const assistantMsg = {
383
+ id: `assistant_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
384
+ conversationId: currentConversationId,
385
+ messageContent: content,
386
+ messageType: 'assistant' as const,
387
+ attachments: [],
388
+ isCommit: false,
389
+ userId: 'ai_assistant',
390
+ createdAt: new Date(),
391
+ updatedAt: new Date(),
392
+ _optimistic: false,
393
+ _failed: false,
394
+ _retryCount: 0
395
+ };
396
+ chatStore.actions.addMessage(currentConversationId, assistantMsg);
397
+
398
+ // Mark HEAD as soon as an assistant message carries a widget commit — this makes the
399
+ // Restore Checkpoint button available on previous commits even before "Preview ready".
400
+ if (
401
+ /Commit:?\s+[a-f0-9]{7,40}/i.test(content) &&
402
+ !/^Code generation failed/i.test(content.trim())
403
+ ) {
404
+ markWidgetHeadAtLatestCommit(assistantMsg.id);
405
+ }
406
+
407
+ if (onSaveMessage && !skipPersist) {
408
+ const convId = currentConversationId;
409
+ onSaveMessage(convId, content, 'assistant').then(saved => {
410
+ if (saved?.id && saved.id !== assistantMsg.id) {
411
+ chatStore.actions.updateMessageId(convId, assistantMsg.id, saved.id);
412
+ }
413
+ }).catch(err =>
414
+ chatWarn('[Chat] Failed to persist assistant message:', err)
415
+ );
416
+ }
417
+ }
418
+
419
+ /** Add a user message to the UI without triggering generation (called by parent via chatMethods).
420
+ * Also persists to DB via onSaveMessage so multi-turn history is complete. */
421
+ function addUserMessage(content: string, attachments?: Array<{ id: string; url: string; file_type: string; file_name: string; file_size?: number }>) {
422
+ if (!currentConversationId) return;
423
+ const userMsg = {
424
+ id: `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
425
+ conversationId: currentConversationId,
426
+ messageContent: content,
427
+ messageType: 'user' as const,
428
+ attachments: attachments || [],
429
+ isCommit: false,
430
+ userId: 'current_user',
431
+ createdAt: new Date(),
432
+ updatedAt: new Date(),
433
+ _optimistic: false,
434
+ _failed: false,
435
+ _retryCount: 0
436
+ };
437
+ chatStore.actions.addMessage(currentConversationId, userMsg);
438
+
439
+ if (onSaveMessage) {
440
+ const convId = currentConversationId;
441
+ onSaveMessage(convId, content, 'user', attachments).then(saved => {
442
+ if (saved?.id && saved.id !== userMsg.id) {
443
+ chatStore.actions.updateMessageId(convId, userMsg.id, saved.id);
444
+ }
445
+ }).catch(err =>
446
+ chatWarn('[Chat] Failed to persist user message:', err)
447
+ );
448
+ }
449
+ }
450
+
451
+ // ActionBar event handlers
452
+ async function handleScreenshot({ detail }: { detail: { contextId: string } }) {
453
+ chatLog('[Chat] Screenshot requested for context:', detail.contextId);
454
+
455
+ try {
456
+ let screenshotDataUrl: string | null = null;
457
+
458
+ if (onTakeScreenshot) {
459
+ screenshotDataUrl = await onTakeScreenshot();
460
+ }
461
+
462
+ if (!screenshotDataUrl) {
463
+ chatWarn('[Chat] Screenshot capture returned null');
464
+ chatStore.actions.updateUI({ errorMessage: 'Screenshot capture failed — no preview available.' });
465
+ return;
466
+ }
467
+
468
+ // Convert data URL to File for attachment
469
+ const response = await fetch(screenshotDataUrl);
470
+ const blob = await response.blob();
471
+ const screenshotFile = new File([blob], 'screenshot.png', { type: 'image/png' });
472
+
473
+ const attachmentInput: AttachmentInput = {
474
+ file: screenshotFile,
475
+ type: 'image',
476
+ preview: screenshotDataUrl,
477
+ uploadProgress: 0
478
+ };
479
+
480
+ localAttachments = [...localAttachments, attachmentInput];
481
+ const attachmentIndex = localAttachments.length - 1;
482
+ notifyDraftChange();
483
+
484
+ if (onUploadChatFile) {
485
+ await uploadAttachmentToCdn(screenshotFile, attachmentInput, attachmentIndex, {
486
+ source: 'preview-screenshot',
487
+ });
488
+ }
489
+
490
+ chatLog('Screenshot captured and added to attachments');
491
+ } catch (error) {
492
+ console.error('Screenshot capture failed:', error);
493
+ chatStore.actions.updateUI({
494
+ errorMessage: 'Failed to capture screenshot'
495
+ });
496
+ }
497
+ }
498
+
499
+ async function handleRecordStart({ detail }: { detail: { contextId: string } }) {
500
+ chatLog('Recording start requested for context:', detail.contextId);
501
+
502
+ try {
503
+ localIsRecording = true;
504
+
505
+ // This would integrate with the existing recording module
506
+ chatLog('Recording started...');
507
+
508
+ } catch (error) {
509
+ console.error('Failed to start recording:', error);
510
+ localIsRecording = false;
511
+ chatStore.actions.updateUI({
512
+ errorMessage: 'Failed to start recording'
513
+ });
514
+ }
515
+ }
516
+
517
+ async function handleRecordStop({ detail }: { detail: { contextId: string } }) {
518
+ chatLog('Recording stop requested for context:', detail.contextId);
519
+
520
+ try {
521
+ localIsRecording = false;
522
+
523
+ // Mock recording implementation - replace with actual capture
524
+ const mockRecording = new File(['mock-recording-data'], 'recording.webm', {
525
+ type: 'video/webm'
526
+ });
527
+
528
+ // Create AttachmentInput object
529
+ const attachmentInput: AttachmentInput = {
530
+ file: mockRecording,
531
+ type: 'video',
532
+ uploadProgress: 0
533
+ };
534
+
535
+ // Add to attachments
536
+ localAttachments = [...localAttachments, attachmentInput];
537
+
538
+ chatLog('Recording stopped and added to attachments');
539
+
540
+ } catch (error) {
541
+ console.error('Failed to stop recording:', error);
542
+ chatStore.actions.updateUI({
543
+ errorMessage: 'Failed to stop recording'
544
+ });
545
+ }
546
+ }
547
+
548
+ function isCdnUploadableFile(file: File): boolean {
549
+ return (
550
+ file.type.startsWith('image/') ||
551
+ file.type.startsWith('video/') ||
552
+ file.type === 'application/pdf'
553
+ );
554
+ }
555
+
556
+ function bumpAttachmentProgress(attachmentIndex: number, percentage: number) {
557
+ localAttachments = localAttachments.map((att, idx) =>
558
+ idx === attachmentIndex ? { ...att, uploadProgress: percentage } : att,
559
+ );
560
+ }
561
+
562
+ async function uploadAttachmentToCdn(
563
+ file: File,
564
+ entry: AttachmentInput,
565
+ attachmentIndex: number,
566
+ options: { source?: string } = {},
567
+ ): Promise<void> {
568
+ if (!onUploadChatFile || !isCdnUploadableFile(file)) {
569
+ if (file.type.startsWith('image/')) {
570
+ entry.preview = URL.createObjectURL(file);
571
+ }
572
+ return;
573
+ }
574
+
575
+ try {
576
+ bumpAttachmentProgress(attachmentIndex, 0);
577
+ const uploaded = await onUploadChatFile(file, {
578
+ onProgress: (percentage) => bumpAttachmentProgress(attachmentIndex, percentage),
579
+ source: options.source,
580
+ });
581
+ entry.uploadedUrl = uploaded.url;
582
+ entry.uploadId = uploaded.id;
583
+ if (file.type.startsWith('image/')) {
584
+ entry.preview = uploaded.url;
585
+ }
586
+ entry.uploadProgress = 100;
587
+ bumpAttachmentProgress(attachmentIndex, 100);
588
+ localAttachments = [...localAttachments];
589
+ notifyDraftChange();
590
+ chatLog('[Chat] Attachment uploaded to CDN:', uploaded.url.slice(0, 80), 'id:', uploaded.id);
591
+ } catch (uploadError) {
592
+ console.error('[Chat] Upload failed, using local preview where possible:', uploadError);
593
+ if (file.type.startsWith('image/')) {
594
+ entry.preview = URL.createObjectURL(file);
595
+ }
596
+ entry.uploadProgress = 0;
597
+ bumpAttachmentProgress(attachmentIndex, 0);
598
+ // Soft warning only — local File still works for codegen vision; don't block the chat UI.
599
+ const detail =
600
+ uploadError instanceof Error ? uploadError.message : 'Attachment upload failed.';
601
+ console.warn('[Chat] CDN upload unavailable; continuing with local attachment:', detail);
602
+ }
603
+ }
604
+
605
+ async function handleUpload() {
606
+ chatLog('File upload requested');
607
+
608
+ const fileInput = document.createElement('input');
609
+ fileInput.type = 'file';
610
+ fileInput.multiple = true;
611
+ fileInput.accept = 'image/*,video/*,.pdf,.txt,.md,.mermaid';
612
+
613
+ fileInput.onchange = async (event) => {
614
+ const files = Array.from((event.target as HTMLInputElement).files || []);
615
+ if (files.length === 0) return;
616
+
617
+ const newAttachments: AttachmentInput[] = files.map((file) => ({
618
+ file,
619
+ type: file.type.startsWith('image/')
620
+ ? 'image'
621
+ : file.type.startsWith('video/')
622
+ ? 'video'
623
+ : 'document',
624
+ uploadProgress: 0,
625
+ }));
626
+
627
+ const startIndex = localAttachments.length;
628
+ localAttachments = [...localAttachments, ...newAttachments];
629
+ notifyDraftChange();
630
+
631
+ try {
632
+ localIsUploading = true;
633
+ for (let i = 0; i < files.length; i++) {
634
+ const file = files[i];
635
+ const entry = newAttachments[i];
636
+ await uploadAttachmentToCdn(file, entry, startIndex + i);
637
+ }
638
+ } finally {
639
+ localIsUploading = false;
640
+ }
641
+
642
+ chatLog('Files selected via upload:', files.length);
643
+ };
644
+
645
+ fileInput.click();
646
+ }
647
+
648
+ async function handleComplete({ detail }: { detail: { completed: boolean } }) {
649
+ chatLog('Conversation completion toggle requested');
650
+
651
+ if (!currentConversationId) {
652
+ console.error('No active conversation to mark as complete');
653
+ return;
654
+ }
655
+
656
+ try {
657
+ // Call parent's completion toggle function
658
+ if (onCompletionToggle) {
659
+ onCompletionToggle();
660
+ chatLog('Completion toggled via parent callback');
661
+ } else {
662
+ chatWarn('No onCompletionToggle callback provided');
663
+ }
664
+
665
+ } catch (error) {
666
+ console.error('Failed to update conversation status:', error);
667
+ chatStore.actions.updateUI({
668
+ errorMessage: 'Failed to update conversation status'
669
+ });
670
+ }
671
+ }
672
+
673
+ function handleActionError({ detail }: { detail: { type: string; error: any } }) {
674
+ console.error(`ActionBar ${detail.type} error:`, detail.error);
675
+ chatStore.actions.updateUI({
676
+ errorMessage: `${detail.type} action failed. Please try again.`
677
+ });
678
+ }
679
+
680
+ async function handleSuggestionSelected(suggestion: string) {
681
+ chatLog('Suggestion selected in Chat — autocompleting into input:', suggestion);
682
+ // Autocomplete into input instead of sending immediately — lets user review,
683
+ // edit, and attach an image before sending (e.g. "Generate code from this design"
684
+ // is useless without an attached screenshot).
685
+ setDraftText(suggestion);
686
+ }
687
+
688
+ async function handleRetryMessage(content: string) {
689
+ const retryContent = content.trim();
690
+ if (!retryContent) return;
691
+ chatLog('[Chat] Retrying failed message:', retryContent.slice(0, 80));
692
+ if (onRetryMessage) {
693
+ await onRetryMessage(retryContent);
694
+ return;
695
+ }
696
+ await sendMessage(retryContent);
697
+ }
698
+
699
+ async function handleRestoreCheckpoint(messageId: string, messageIndex: number) {
700
+ chatLog('Restore checkpoint requested:', { messageId, messageIndex, conversationId: currentConversationId });
701
+
702
+ if (!currentConversationId || !currentMessages) {
703
+ console.error('No active conversation or messages to restore');
704
+ return;
705
+ }
706
+
707
+ try {
708
+ const clicked = currentMessages[messageIndex];
709
+ let checkpointUserId = messageId;
710
+ let headAssistantId = clicked?.id;
711
+
712
+ // API rolls back to the commit produced by the user prompt before this assistant reply.
713
+ if (clicked?.messageType === 'assistant') {
714
+ headAssistantId = clicked.id;
715
+ for (let i = messageIndex - 1; i >= 0; i--) {
716
+ if (currentMessages[i].messageType === 'user') {
717
+ checkpointUserId = currentMessages[i].id;
718
+ break;
719
+ }
720
+ }
721
+ } else {
722
+ for (let i = messageIndex + 1; i < currentMessages.length; i++) {
723
+ if (currentMessages[i].messageType === 'assistant' && messageHasWidgetCommit(currentMessages[i].messageContent)) {
724
+ headAssistantId = currentMessages[i].id;
725
+ break;
726
+ }
727
+ }
728
+ }
729
+
730
+ const updatedMessages = currentMessages.map((msg: any) => ({
731
+ ...msg,
732
+ isWidgetHead: headAssistantId ? msg.id === headAssistantId : false,
733
+ rolledBackAt: null,
734
+ }));
735
+
736
+ chatLog(`Restoring to checkpoint user message ${checkpointUserId}, head assistant ${headAssistantId}`);
737
+ chatStore.actions.setMessages(currentConversationId, updatedMessages);
738
+
739
+ if (onRestoreCheckpointBackend) {
740
+ try {
741
+ const result = await onRestoreCheckpointBackend(currentConversationId, checkpointUserId);
742
+ chatLog('[Chat] Checkpoint restored on backend:', result);
743
+ if (headAssistantId) {
744
+ markWidgetHeadAtLatestCommit(headAssistantId);
745
+ }
746
+ } catch (backendError) {
747
+ console.error('[Chat] Backend checkpoint restore failed, local state updated:', backendError);
748
+ }
749
+ }
750
+
751
+ chatStore.actions.updateUI({ errorMessage: null });
752
+ chatLog('Checkpoint restored successfully');
753
+
754
+ } catch (error) {
755
+ console.error('Failed to restore checkpoint:', error);
756
+ chatStore.actions.updateUI({
757
+ errorMessage: 'Failed to restore checkpoint. Please try again.'
758
+ });
759
+ }
760
+ }
761
+
762
+ function isValidImageDataUrl(dataUrl: string): boolean {
763
+ if (!dataUrl?.startsWith('data:image/')) return false;
764
+ const base64 = dataUrl.split(',')[1];
765
+ if (!base64 || base64.length < 80) return false;
766
+ try {
767
+ atob(base64.slice(0, 32));
768
+ return true;
769
+ } catch {
770
+ return false;
771
+ }
772
+ }
773
+
774
+ async function addImageAttachment(dataUrl: string, fileName = 'pasted-image.png') {
775
+ if (!isValidImageDataUrl(dataUrl)) {
776
+ chatWarn('[Chat] Skipping invalid image data URL for attachment restore');
777
+ return;
778
+ }
779
+ try {
780
+ const byteString = atob(dataUrl.split(',')[1]);
781
+ const mimeMatch = dataUrl.match(/data:(image\/[^;]+);/);
782
+ const mimeType = mimeMatch?.[1] || 'image/png';
783
+ const ab = new ArrayBuffer(byteString.length);
784
+ const ia = new Uint8Array(ab);
785
+ for (let i = 0; i < byteString.length; i++) ia[i] = byteString.charCodeAt(i);
786
+ const file = new File([ab], fileName, { type: mimeType });
787
+ const attachmentInput: AttachmentInput = {
788
+ file,
789
+ type: 'image',
790
+ preview: dataUrl,
791
+ uploadProgress: 0,
792
+ };
793
+ const attachmentIndex = localAttachments.length;
794
+ localAttachments = [...localAttachments, attachmentInput];
795
+ notifyDraftChange();
796
+ if (onUploadChatFile) {
797
+ await uploadAttachmentToCdn(file, attachmentInput, attachmentIndex);
798
+ }
799
+ chatLog('Image attachment added from external paste');
800
+ } catch (err) {
801
+ console.error('[Chat] Failed to add image attachment:', err);
802
+ }
803
+ }
804
+
805
+ function getDraftText(): string {
806
+ return localInputText;
807
+ }
808
+
809
+ function setDraftText(text: string) {
810
+ localInputText = text;
811
+ }
812
+
813
+ function getAttachmentDataUrls(): string[] {
814
+ return localAttachments
815
+ .filter((att) => att.type === 'image' && (att.preview || att.file.type.startsWith('image/')))
816
+ .map((att) => att.preview || '');
817
+ }
818
+
819
+ function getAttachmentUploadIds(): string[] {
820
+ return localAttachments
821
+ .filter((att) => att.type === 'image' && att.uploadId)
822
+ .map((att) => att.uploadId as string);
823
+ }
824
+
825
+ function restoreAttachmentsFromDataUrls(dataUrls: string[]) {
826
+ localAttachments = [];
827
+ const validUrls = dataUrls.filter(isValidImageDataUrl);
828
+ for (let i = 0; i < validUrls.length; i++) {
829
+ addImageAttachment(validUrls[i], `restored-image-${i + 1}.png`);
830
+ }
831
+ if (validUrls.length < dataUrls.length) {
832
+ chatWarn('[Chat] Dropped invalid restored attachment(s):', dataUrls.length - validUrls.length);
833
+ }
834
+ }
835
+
836
+ function clearDraft() {
837
+ localInputText = '';
838
+ localAttachments = [];
839
+ notifyDraftChange();
840
+ }
841
+
842
+ const CODEGEN_COMMIT_REGEX = /Commit:?\s+([a-f0-9]{7,40})/i;
843
+
844
+ function isPreviewRecompiledMessage(content?: string | null): boolean {
845
+ return /^Preview recompiled/i.test((content || '').trim());
846
+ }
847
+
848
+ /** Backend summary: "Updated N files...\\n\\nCommit {hash}" */
849
+ function isBackendCodegenSummary(content?: string | null): boolean {
850
+ const trimmed = (content || '').trim();
851
+ return /^Updated \d+ files?/i.test(trimmed) && /\n\nCommit\s+[a-f0-9]{7,40}/i.test(trimmed);
852
+ }
853
+
854
+ /** Widget HEAD applies only to codegen completion lines, not preview-rebuild status. */
855
+ function messageHasWidgetCommit(content?: string | null): boolean {
856
+ 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;
860
+ return isBackendCodegenSummary(trimmed);
861
+ }
862
+
863
+ function getLastUserMessageIndex(msgs: { messageType?: string }[]): number {
864
+ for (let i = msgs.length - 1; i >= 0; i--) {
865
+ if (msgs[i].messageType === 'user') return i;
866
+ }
867
+ return -1;
868
+ }
869
+
870
+ /** In-flight codegen / agent harness status (updated in place until completion). */
871
+ function isCodegenProgressMessage(content?: string | null): boolean {
872
+ const trimmed = (content || '').trim();
873
+ if (!trimmed) return false;
874
+ return (
875
+ /^Code generation in progress/i.test(trimmed) ||
876
+ /^Generating code/i.test(trimmed) ||
877
+ /^Agent fixing/i.test(trimmed) ||
878
+ /^Building preview/i.test(trimmed) ||
879
+ /^Parsing generated/i.test(trimmed) ||
880
+ /^Planning/i.test(trimmed) ||
881
+ /^Preparing prompt/i.test(trimmed) ||
882
+ /^Reading existing code/i.test(trimmed) ||
883
+ /^Saving to repository/i.test(trimmed) ||
884
+ /^Retrying code format/i.test(trimmed)
885
+ );
886
+ }
887
+
888
+ function isTurnCodegenAssistantMessage(content?: string | null): boolean {
889
+ return (
890
+ isCodegenStatusMessage(content) ||
891
+ isBackendCodegenSummary(content) ||
892
+ messageHasWidgetCommit(content) ||
893
+ isCodegenProgressMessage(content)
894
+ );
895
+ }
896
+
897
+ function isCodegenFailureMessage(content?: string | null): boolean {
898
+ const t = (content || '').trim();
899
+ return /^Code generation failed/i.test(t);
900
+ }
901
+
902
+ function markWidgetHeadAtLatestCommit(preferredMessageId?: string) {
903
+ if (!currentConversationId) return;
904
+ const msgs = getCurrentMessages(currentConversationId);
905
+ let headId = preferredMessageId;
906
+ if (!headId) {
907
+ for (let i = msgs.length - 1; i >= 0; i--) {
908
+ const m = msgs[i];
909
+ if (m.messageType !== 'assistant') continue;
910
+ if (isCodegenFailureMessage(m.messageContent)) continue;
911
+ if (messageHasWidgetCommit(m.messageContent)) {
912
+ headId = m.id;
913
+ break;
914
+ }
915
+ }
916
+ }
917
+ if (!headId) return;
918
+ const updated = msgs.map((m) => ({
919
+ ...m,
920
+ isWidgetHead: m.id === headId,
921
+ rolledBackAt: null,
922
+ }));
923
+ chatStore.actions.setMessages(currentConversationId, updated);
924
+ chatLog('[Chat] Widget HEAD marked on message:', headId);
925
+ }
926
+
927
+ const CODEGEN_STATUS_MESSAGE_REGEX = /^Code generation completed/i;
928
+
929
+ function isCodegenStatusMessage(content?: string | null): boolean {
930
+ return CODEGEN_STATUS_MESSAGE_REGEX.test((content || '').trim());
931
+ }
932
+
933
+ /** One assistant bubble per user turn: loading → ready (in-place update within current turn only). */
934
+ function updateLatestCodegenStatusMessage(content: string) {
935
+ if (!currentConversationId) return;
936
+ const msgs = getCurrentMessages(currentConversationId);
937
+ const turnStart = getLastUserMessageIndex(msgs) + 1;
938
+
939
+ let targetId: string | null = null;
940
+ for (let i = msgs.length - 1; i >= turnStart; i--) {
941
+ const m = msgs[i];
942
+ if (m.messageType !== 'assistant') continue;
943
+ if (isTurnCodegenAssistantMessage(m.messageContent)) {
944
+ targetId = m.id;
945
+ break;
946
+ }
947
+ }
948
+
949
+ if (targetId) {
950
+ chatStore.actions.updateMessageContent(currentConversationId, targetId, content);
951
+ const deduped = msgs.filter((m, idx) => {
952
+ if (idx < turnStart || m.messageType !== 'assistant') return true;
953
+ if (!isTurnCodegenAssistantMessage(m.messageContent)) return true;
954
+ return m.id === targetId;
955
+ });
956
+ if (deduped.length !== msgs.length) {
957
+ chatStore.actions.setMessages(currentConversationId, deduped);
958
+ }
959
+ // Mark HEAD as soon as the assistant message carries a commit — do not wait for "Preview ready".
960
+ // Restore Checkpoint buttons require some message to be HEAD, so we mark eagerly on any commit.
961
+ if (messageHasWidgetCommit(content)) {
962
+ markWidgetHeadAtLatestCommit(targetId);
963
+ }
964
+ return;
965
+ }
966
+
967
+ for (let i = msgs.length - 1; i >= turnStart; i--) {
968
+ const m = msgs[i];
969
+ if (m.messageType !== 'assistant') continue;
970
+ if (isCodegenFailureMessage(m.messageContent)) {
971
+ chatStore.actions.updateMessageContent(currentConversationId, m.id, content);
972
+ if (messageHasWidgetCommit(content)) {
973
+ markWidgetHeadAtLatestCommit(m.id);
974
+ }
975
+ return;
976
+ }
977
+ }
978
+ addAssistantMessage(content, true);
979
+ if (messageHasWidgetCommit(content)) {
980
+ markWidgetHeadAtLatestCommit();
981
+ }
982
+ }
983
+
984
+ /** Reload messages from backend and re-mark widget HEAD (picks up server-persisted summaries). */
985
+ async function reloadConversationMessages() {
986
+ if (!currentConversationId || !onLoadMessages) return;
987
+ try {
988
+ const loaded = await onLoadMessages(currentConversationId);
989
+ if (loaded?.length) {
990
+ chatStore.actions.setMessages(currentConversationId, loaded);
991
+ }
992
+ markWidgetHeadAtLatestCommit();
993
+ chatLog('[Chat] Reloaded conversation messages:', loaded?.length ?? 0);
994
+ } catch (err) {
995
+ chatWarn('[Chat] Failed to reload conversation messages:', err);
996
+ }
997
+ }
998
+
999
+ function clearStalePreviewBuildFailures() {
1000
+ if (!currentConversationId) return;
1001
+ const msgs = getCurrentMessages(currentConversationId);
1002
+ const previewFailedSuffix = /\n\nPreview build failed:[\s\S]*$/i;
1003
+ for (let i = msgs.length - 1; i >= 0; i--) {
1004
+ const msg = msgs[i];
1005
+ if (msg.messageType !== 'assistant') continue;
1006
+ const content = msg.messageContent || '';
1007
+ if (!/preview build failed/i.test(content)) continue;
1008
+ const cleaned = content.replace(previewFailedSuffix, '').trim();
1009
+ chatStore.actions.updateMessageContent(currentConversationId, msg.id, cleaned);
1010
+ chatLog('[Chat] Cleared stale preview build failure from assistant message:', msg.id);
1011
+ break;
1012
+ }
1013
+ }
1014
+
1015
+ // Expose methods to parent component — fire once per context, not on every reactive tick
1016
+ let lastChatReadyContextId: string | null = null;
1017
+ $effect(() => {
1018
+ if (!isInitialized || !onChatReady || !context?.id) return;
1019
+ if (lastChatReadyContextId === context.id) return;
1020
+ lastChatReadyContextId = context.id;
1021
+
1022
+ const chatMethods: ChatMethods = {
1023
+ sendMessage,
1024
+ addAssistantMessage,
1025
+ addUserMessage,
1026
+ getConversationId: () => currentConversationId,
1027
+ addImageAttachment,
1028
+ getDraftText,
1029
+ setDraftText,
1030
+ getAttachmentDataUrls,
1031
+ getAttachmentUploadIds,
1032
+ restoreAttachmentsFromDataUrls,
1033
+ clearDraft,
1034
+ clearStalePreviewBuildFailures,
1035
+ markWidgetHeadAtLatestCommit,
1036
+ updateLatestCodegenStatusMessage,
1037
+ reloadConversationMessages,
1038
+ };
1039
+ onChatReady(chatMethods);
1040
+ });
1041
+
1042
+ // Notify parent when conversation ID changes — compare by value only.
1043
+ // Parent often passes an inline callback; tracking that reference would re-run every render
1044
+ // and can trigger effect_update_depth_exceeded.
1045
+ let lastReportedConversationId: string | null | undefined = undefined;
1046
+ $effect(() => {
1047
+ const convId = currentConversationId;
1048
+ if (!onConversationChange) return;
1049
+ if (lastReportedConversationId === convId) return;
1050
+ lastReportedConversationId = convId;
1051
+ onConversationChange(convId);
1052
+ });
1053
+
1054
+ // Handle context changes
1055
+ $effect(() => {
1056
+ if (context) {
1057
+ chatStore.actions.setActiveContext(context.id, true);
1058
+ }
1059
+ });
1060
+
1061
+ // Update parent with stats when messages change
1062
+ $effect(() => {
1063
+ if (onStatsUpdate && currentMessages) {
1064
+ const messageCount = currentMessages.length;
1065
+ const firstMessage = currentMessages.length > 0 ? currentMessages[0].messageContent || null : null;
1066
+ onStatsUpdate(messageCount, firstMessage);
1067
+ }
1068
+ });
1069
+
1070
+ // Debug reactive values
1071
+ $effect(() => {
1072
+ chatLog('Chat render state for', context.id, ':', {
1073
+ isInitialized,
1074
+ chatState: !!chatState,
1075
+ currentConversationId,
1076
+ messagesLength: currentMessages?.length || 0
1077
+ });
1078
+ });
1079
+
1080
+ // ── Messages area vertical resize ────────────────────────────────────
1081
+ // When null, the messages list uses flex-1 (fills remaining space).
1082
+ // When set (px), the user has dragged the handle so we use that fixed height.
1083
+ let messagesAreaHeight: number | null = $state(null);
1084
+ let isResizingMessages = $state(false);
1085
+ const MIN_MESSAGES_HEIGHT = 120;
1086
+
1087
+ function handleMessagesResizeStart(e: PointerEvent) {
1088
+ e.preventDefault();
1089
+ isResizingMessages = true;
1090
+ const handle = e.currentTarget as HTMLElement;
1091
+ const messagesEl = handle.previousElementSibling as HTMLElement | null;
1092
+ if (!messagesEl) return;
1093
+ const startY = e.clientY;
1094
+ const startHeight = messagesEl.getBoundingClientRect().height;
1095
+ let lastDy = 0;
1096
+
1097
+ const onMove = (ev: PointerEvent) => {
1098
+ const dy = ev.clientY - startY;
1099
+ 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
+ const frameDelta = dy - lastDy;
1103
+ lastDy = dy;
1104
+ if (frameDelta !== 0) onMessagesResize?.(frameDelta);
1105
+ } else {
1106
+ const next = Math.max(MIN_MESSAGES_HEIGHT, startHeight + dy);
1107
+ messagesAreaHeight = next;
1108
+ }
1109
+ };
1110
+ const onUp = () => {
1111
+ isResizingMessages = false;
1112
+ window.removeEventListener('pointermove', onMove);
1113
+ window.removeEventListener('pointerup', onUp);
1114
+ };
1115
+ window.addEventListener('pointermove', onMove);
1116
+ window.addEventListener('pointerup', onUp);
1117
+ }
1118
+ </script>
1119
+
1120
+ <div
1121
+ 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"
1123
+ style={style}
1124
+ >
1125
+ {#if showHeader}
1126
+ <!-- Simple Chat Header -->
1127
+ <div class="prompt-chat-header {showDescriptionHeader ? 'p-4' : 'px-4 py-2'} border-b border-gray-200 bg-gray-50">
1128
+ <div class="flex items-center gap-3">
1129
+ <div class="min-w-0 flex-1">
1130
+ <h3 class="font-semibold text-gray-900 text-sm truncate">
1131
+ {headerTitle || context.title || context.id}
1132
+ </h3>
1133
+
1134
+ {#if showDescriptionHeader}
1135
+ <div class="context-type-ct flex items-center gap-2 mt-1">
1136
+ <span class="context-type-icon text-sm">
1137
+ {#if context.type === 'frame'}📱{:else if context.type === 'text'}📄{:else if context.type === 'image'}🖼️{:else if context.type === 'document'}📋{:else}💬{/if}
1138
+ </span>
1139
+ <span class="text-xs text-gray-500">
1140
+ {context.type} chat
1141
+ </span>
1142
+ <StatusIndicator
1143
+ status={isCompleted ? 'completed' : (isLoading ? 'loading' : 'active')}
1144
+ messageCount={currentMessages?.length || 0}
1145
+ showText={false}
1146
+ size="sm"
1147
+ />
1148
+ </div>
1149
+ {/if}
1150
+ </div>
1151
+ </div>
1152
+ </div>
1153
+ {/if}
1154
+
1155
+ <!-- Chat Interface -->
1156
+ {#if isInitialized}
1157
+ <div
1158
+ class="prompt-chat-messages-area overflow-hidden"
1159
+ style="{messagesAreaHeight !== null
1160
+ ? `flex: 0 0 ${messagesAreaHeight}px; height: ${messagesAreaHeight}px;`
1161
+ : 'flex: 1 1 auto; min-height: 0;'}"
1162
+ >
1163
+ <ChatMessages
1164
+ class="prompt-chat-messages h-full"
1165
+ messages={currentMessages || []}
1166
+ isLoading={isLoading}
1167
+ {context}
1168
+ isCompleted={isCompleted}
1169
+ enableAutoCodeDetection={config?.features?.autoCodeDetection === true}
1170
+ showGenerationSummaries={config?.features?.showGenerationSummaries === true}
1171
+ onSuggestionSelected={handleSuggestionSelected}
1172
+ onRestoreCheckpoint={handleRestoreCheckpoint}
1173
+ onRetryMessage={handleRetryMessage}
1174
+ />
1175
+ </div>
1176
+
1177
+ <div
1178
+ class="prompt-chat-messages-resize-handle flex items-center justify-center cursor-ns-resize select-none group border-t border-b border-transparent hover:border-gray-300 hover:bg-gray-100/80 transition-colors"
1179
+ class:bg-blue-100={isResizingMessages}
1180
+ class:border-blue-300={isResizingMessages}
1181
+ style="height: 16px; touch-action: none; padding: 4px 0;"
1182
+ onpointerdown={handleMessagesResizeStart}
1183
+ role="separator"
1184
+ aria-orientation="horizontal"
1185
+ aria-label="Resize messages area"
1186
+ title="Drag to resize messages area"
1187
+ >
1188
+ <div class="drag-grip w-10 h-1 rounded-full bg-gray-300 group-hover:bg-gray-500 group-hover:w-14 transition-all {isResizingMessages ? 'bg-blue-400 w-14' : ''}"></div>
1189
+ </div>
1190
+
1191
+ <ActionBar
1192
+ {context}
1193
+ conversationId={currentConversationId}
1194
+ isRecording={localIsRecording}
1195
+ isCompleted={isCompleted}
1196
+ disabled={!isInitialized || isLoading}
1197
+ showScreenshot={config?.features?.screenshot !== false}
1198
+ showRecord={config?.features?.screenRecording !== false}
1199
+ showUpload={config?.features?.fileUpload !== false}
1200
+ showComplete={config?.features?.restoreCheckpoint !== false}
1201
+ on:screenshot={handleScreenshot}
1202
+ on:recordStart={handleRecordStart}
1203
+ on:recordStop={handleRecordStop}
1204
+ on:upload={handleUpload}
1205
+ on:complete={handleComplete}
1206
+ on:error={handleActionError}
1207
+ />
1208
+
1209
+ <ChatInput
1210
+ value={localInputText}
1211
+ attachments={localAttachments}
1212
+ isRecording={localIsRecording}
1213
+ isSending={localIsSending}
1214
+ isUploading={localIsUploading}
1215
+ onSend={(content, attachmentInputs) => {
1216
+ sendMessage(content, attachmentInputs);
1217
+ }}
1218
+ onInputChange={(text) => {
1219
+ chatLog('Input change for chat', context.id, ':', text);
1220
+ localInputText = text;
1221
+ notifyDraftChange();
1222
+ }}
1223
+ onAttachmentsChange={(attachments) => {
1224
+ chatLog('Attachments change for chat', context.id, ':', attachments.length);
1225
+ localAttachments = attachments;
1226
+ notifyDraftChange();
1227
+ }}
1228
+ />
1229
+ {:else}
1230
+ <!-- Loading State -->
1231
+ <div class="loading-indicator-ct flex-1 flex items-center justify-center">
1232
+ <div class="text-center">
1233
+ <div class="w-8 h-8 border-2 border-gray-300 border-t-blue-600 rounded-full animate-spin mx-auto mb-4"></div>
1234
+ <p class="text-gray-600 text-sm">Initializing chat...</p>
1235
+ </div>
1236
+ </div>
1237
+ {/if}
1238
+
1239
+ <!-- Error Display -->
1240
+ {#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>
1249
+ </div>
1250
+ {/if}
1251
+ </div>
1252
+
1253
+ <!-- No styles needed now -->