@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,996 @@
1
+ <script lang="ts">
2
+ import type { ChatMessage, ChatContext } from '../types/index.js';
3
+ import { MESSAGE_COLORS } from '../constants/colors.js';
4
+ import { chatLog } from '../utils/logger.js';
5
+ import { AttachmentDisplay, SuggestionButtons } from './index.js';
6
+ import CodeBlock from './CodeBlock.svelte';
7
+ import LoadingIndicator from './LoadingIndicator.svelte';
8
+ import { onMount, tick } from 'svelte';
9
+
10
+ // Props
11
+ interface Props {
12
+ className?: className;
13
+ messages: ChatMessage[];
14
+ isLoading?: boolean;
15
+ context: ChatContext;
16
+ isCompleted?: boolean;
17
+ /**
18
+ * When true, heuristic code pattern detection (`detectCodePatterns`) runs
19
+ * for messages without explicit ``` fences. Default false — keeps chat UI
20
+ * clean when assistants return summaries that mention file paths or
21
+ * extensions (e.g. "Updated 4 files: ...svelte") which would otherwise be
22
+ * misclassified as JavaScript code blocks. Enable only for debugging.
23
+ */
24
+ enableAutoCodeDetection?: boolean;
25
+ /**
26
+ * When false, backend-generated "Updated N files: ..." summaries are
27
+ * hidden from the UI (they still exist in the DB as LLM context). Default
28
+ * false — noisy for end users. Typically bound to `debugMode` by hosts.
29
+ */
30
+ showGenerationSummaries?: boolean;
31
+ onSuggestionSelected?: (suggestion: string) => void;
32
+ onRestoreCheckpoint?: (messageId: string, messageIndex: number) => void;
33
+ onRetryMessage?: (content: string) => void;
34
+ }
35
+
36
+ let {
37
+ class: className = '',
38
+ messages,
39
+ isLoading = false,
40
+ context,
41
+ isCompleted = false,
42
+ enableAutoCodeDetection = false,
43
+ showGenerationSummaries = false,
44
+ onSuggestionSelected,
45
+ onRestoreCheckpoint,
46
+ onRetryMessage,
47
+ }: Props = $props();
48
+
49
+ // Matches "Updated N file(s)[...]: ..." assistant summaries emitted by the
50
+ // widget codegen backend. Anchored to first line only — regular assistant
51
+ // chat replies that happen to contain this phrase mid-message are kept.
52
+ const GENERATION_SUMMARY_REGEX = /^Updated \d+ files?(?:\s*\([^)]*\))?:/;
53
+
54
+ function isGenerationSummary(message: ChatMessage): boolean {
55
+ if (message.messageType !== 'assistant' || !message.messageContent) return false;
56
+ const trimmed = message.messageContent.trim();
57
+ if (/^Code generation failed/i.test(trimmed)) return false;
58
+ if (/^Code generation completed/i.test(trimmed)) return false;
59
+ if (/^Preview recompiled/i.test(trimmed)) return false;
60
+ return GENERATION_SUMMARY_REGEX.test(trimmed);
61
+ }
62
+
63
+ function isPreviewRecompiledMessage(message: ChatMessage): boolean {
64
+ return message.messageType === 'assistant'
65
+ && !!message.messageContent
66
+ && /^Preview recompiled/i.test(message.messageContent.trim());
67
+ }
68
+
69
+ function extractCommitFromContent(content: string): string | null {
70
+ const match = content.match(/(?:Commit:?\s+|\n\nCommit\s+|\(commit\s+)([a-f0-9]{7,40})/i);
71
+ return match?.[1]?.toLowerCase() ?? null;
72
+ }
73
+
74
+ function isBackendCodegenSummary(content: string): boolean {
75
+ const trimmed = content.trim();
76
+ return /^Updated \d+ files?/i.test(trimmed) && /\n\nCommit\s+[a-f0-9]{7,40}/i.test(trimmed);
77
+ }
78
+
79
+ /** Codegen completion with commit — not preview-rebuild status lines. */
80
+ function messageHasCommit(message: ChatMessage): boolean {
81
+ if (!message.messageContent || isPreviewRecompiledMessage(message)) return false;
82
+ const trimmed = message.messageContent.trim();
83
+ if (isCodegenFailure(message)) return false;
84
+ if (/^Code generation completed/i.test(trimmed) && /Commit:?\s+[a-f0-9]{7,40}/i.test(trimmed)) {
85
+ return true;
86
+ }
87
+ return isBackendCodegenSummary(trimmed);
88
+ }
89
+
90
+ function turnHasCodegenFailure(turn: ChatTurn): boolean {
91
+ return turn.replies.some(isCodegenFailure);
92
+ }
93
+
94
+ function rebalancePreviewRecompiledAcrossTurns(turns: ChatTurn[]): ChatTurn[] {
95
+ const result = turns.map((t) => ({ user: t.user, replies: [...t.replies] }));
96
+
97
+ for (let i = 0; i < result.length; i++) {
98
+ const turn = result[i];
99
+ const turnCodegenCommits = new Set(
100
+ turn.replies
101
+ .filter((r) => messageHasCommit(r))
102
+ .map((r) => extractCommitFromContent(r.messageContent || '')!)
103
+ .filter(Boolean),
104
+ );
105
+
106
+ const kept: ChatMessage[] = [];
107
+ const moved: ChatMessage[] = [];
108
+
109
+ for (const reply of turn.replies) {
110
+ if (!isPreviewRecompiledMessage(reply)) {
111
+ kept.push(reply);
112
+ continue;
113
+ }
114
+ const previewCommit = extractCommitFromContent(reply.messageContent || '');
115
+ const belongsHere =
116
+ !turnHasCodegenFailure(turn)
117
+ && (!previewCommit || turnCodegenCommits.size === 0 || turnCodegenCommits.has(previewCommit));
118
+ if (belongsHere) {
119
+ kept.push(reply);
120
+ } else {
121
+ moved.push(reply);
122
+ }
123
+ }
124
+
125
+ turn.replies = kept;
126
+
127
+ for (const msg of moved) {
128
+ const commit = extractCommitFromContent(msg.messageContent || '');
129
+ let placed = false;
130
+ for (let j = i - 1; j >= 0; j--) {
131
+ const prev = result[j];
132
+ const prevHasCommit = prev.replies.some(
133
+ (r) => commit && extractCommitFromContent(r.messageContent || '') === commit,
134
+ );
135
+ if (prevHasCommit || (j === i - 1 && !turnHasCodegenFailure(prev))) {
136
+ prev.replies.push(msg);
137
+ placed = true;
138
+ break;
139
+ }
140
+ }
141
+ if (!placed && i > 0) {
142
+ result[i - 1].replies.push(msg);
143
+ }
144
+ }
145
+ }
146
+
147
+ return result;
148
+ }
149
+
150
+ function turnHasWidgetHead(turn: ChatTurn): boolean {
151
+ return turn.replies.some((r) => r.isWidgetHead && messageHasCommit(r));
152
+ }
153
+
154
+ function isCodegenFailure(message: ChatMessage): boolean {
155
+ return message.messageType === 'assistant'
156
+ && !!message.messageContent
157
+ && /^Code generation failed/i.test(message.messageContent.trim());
158
+ }
159
+
160
+ function canRestoreToMessage(message: ChatMessage): boolean {
161
+ if (message.isWidgetHead) return false;
162
+ if (!message.messageContent) return false;
163
+ const trimmed = message.messageContent.trim();
164
+ if (/^Preview recompiled/i.test(trimmed)) return false;
165
+ return message.messageType === 'assistant'
166
+ && messageHasCommit(message)
167
+ && !isCodegenFailure(message);
168
+ }
169
+
170
+ function canUndoHeadUserTurn(turn: ChatTurn): boolean {
171
+ return !!turn.user && !!getTurnCommitReply(turn)?.isWidgetHead;
172
+ }
173
+
174
+ function extractCommitShort(content: string): string | null {
175
+ const match = content.match(/(?:Commit:?\s+|\n\nCommit\s+)([a-f0-9]{7,40})/i);
176
+ return match?.[1]?.slice(0, 8) ?? null;
177
+ }
178
+
179
+ /** Assistant reply that carries the widget commit for this turn (if any). */
180
+ function getTurnCommitReply(turn: ChatTurn): ChatMessage | null {
181
+ for (const reply of turn.replies) {
182
+ if (messageHasCommit(reply) && !isCodegenFailure(reply)) return reply;
183
+ }
184
+ return null;
185
+ }
186
+
187
+ /** Whether this assistant bubble should render (vs commit chrome on user message). */
188
+ function shouldShowAssistantReply(reply: ChatMessage): boolean {
189
+ if (isCodegenFailure(reply)) return true;
190
+ if (showGenerationSummaries) return true;
191
+ if (/^Code generation completed/i.test((reply.messageContent || '').trim())) return true;
192
+ if (/^Preview recompiled/i.test((reply.messageContent || '').trim())) return true;
193
+ return !isGenerationSummary(reply);
194
+ }
195
+
196
+ /** Show commit chrome + Restore Checkpoint on the user prompt whenever the turn has a commit.
197
+ * Previously this was gated to Debug OFF (assistant reply hidden), but we want the buttons
198
+ * available in Debug ON too — the assistant summary and the user-side chrome coexist. */
199
+ function showCommitMetaOnUserTurn(turn: ChatTurn): boolean {
200
+ return !!getTurnCommitReply(turn);
201
+ }
202
+
203
+ type ChatTurn = {
204
+ user: ChatMessage | null;
205
+ replies: ChatMessage[];
206
+ };
207
+
208
+ let chatTurns = $state<ChatTurn[]>([]);
209
+
210
+ $effect(() => {
211
+ const turns: ChatTurn[] = [];
212
+ let current: ChatTurn | null = null;
213
+
214
+ for (const message of messages) {
215
+ if (message.messageType === 'user') {
216
+ if (current) turns.push(current);
217
+ current = { user: message, replies: [] };
218
+ } else if (current) {
219
+ current.replies.push(message);
220
+ } else {
221
+ turns.push({ user: null, replies: [message] });
222
+ }
223
+ }
224
+ if (current) turns.push(current);
225
+ chatTurns = rebalancePreviewRecompiledAcrossTurns(turns);
226
+ });
227
+
228
+ function messageImpliesAttachedImage(content: string): boolean {
229
+ return /imaginea ata[sș]at[ăa]|attached (reference )?image|reference image|din imagine/i.test(content);
230
+ }
231
+
232
+ let visibleMessages = $derived(
233
+ showGenerationSummaries
234
+ ? messages
235
+ : messages.filter((m) => !isGenerationSummary(m))
236
+ );
237
+
238
+ // Local state
239
+ let messagesContainer: HTMLElement;
240
+ let showSuggestionsInline = $state(false);
241
+ let autoScroll = $state(true);
242
+
243
+ // Auto-scroll to bottom when new messages arrive or on initial load.
244
+ // Also scrolls on mount/conversation-switch when messages are already present.
245
+ $effect(() => {
246
+ if (messagesContainer && autoScroll && visibleMessages.length > 0) {
247
+ tick().then(() => {
248
+ messagesContainer?.scrollTo({
249
+ top: messagesContainer.scrollHeight,
250
+ behavior: 'smooth'
251
+ });
252
+ });
253
+ }
254
+ });
255
+
256
+ // Force scroll to bottom on initial mount (messages may already be loaded)
257
+ onMount(() => {
258
+ tick().then(() => {
259
+ if (messagesContainer && visibleMessages.length > 0) {
260
+ messagesContainer.scrollTo({ top: messagesContainer.scrollHeight, behavior: 'instant' });
261
+ }
262
+ });
263
+ });
264
+
265
+ function handleScroll() {
266
+ if (!messagesContainer) return;
267
+ const { scrollTop, scrollHeight, clientHeight } = messagesContainer;
268
+ autoScroll = scrollTop + clientHeight >= scrollHeight - 10;
269
+ }
270
+
271
+ function formatTime(date: Date): string {
272
+ return new Intl.DateTimeFormat('en-US', {
273
+ hour: '2-digit',
274
+ minute: '2-digit'
275
+ }).format(date);
276
+ }
277
+
278
+ function handleSuggestionSelected(event: CustomEvent) {
279
+ const suggestion = event.detail.suggestion;
280
+ chatLog('Suggestion selected in ChatMessages:', suggestion);
281
+ if (onSuggestionSelected) {
282
+ onSuggestionSelected(suggestion);
283
+ }
284
+ }
285
+
286
+ function handleRestoreCheckpoint(messageId: string) {
287
+ // Find the index of the message to restore to
288
+ const messageIndex = messages.findIndex(msg => msg.id === messageId);
289
+ if (messageIndex === -1) return;
290
+
291
+ chatLog('Restore checkpoint requested for message:', messageId, 'at index:', messageIndex);
292
+
293
+ // Call the prop callback if provided
294
+ if (onRestoreCheckpoint) {
295
+ onRestoreCheckpoint(messageId, messageIndex);
296
+ }
297
+ }
298
+
299
+ function handleRetryTurn(turn: ChatTurn) {
300
+ const retryContent = turn.user?.messageContent?.trim();
301
+ if (!retryContent) return;
302
+ chatLog('Retry requested for failed codegen turn:', retryContent.slice(0, 80));
303
+ onRetryMessage?.(retryContent);
304
+ }
305
+
306
+ // Auto-detect programming language based on content
307
+ function detectLanguage(content: string): string {
308
+ const trimmed = content.trim().toLowerCase();
309
+
310
+ // HTML detection
311
+ if (/<(!doctype|html|head|body|div|span|p|h[1-6]|img|a|ul|ol|li|table|tr|td|th|form|input|button|script|style)/i.test(content)) {
312
+ return 'html';
313
+ }
314
+
315
+ // CSS detection
316
+ if (/[.#][\w-]+\s*\{[^}]*\}|@(media|import|font-face|keyframes)|[\w-]+\s*:\s*[^;]+;/.test(content)) {
317
+ return 'css';
318
+ }
319
+
320
+ // JSON detection
321
+ if (/^\s*[\{\[]/.test(trimmed) && /[\}\]]\s*$/.test(trimmed)) {
322
+ try {
323
+ JSON.parse(content);
324
+ return 'json';
325
+ } catch (e) {
326
+ // Not valid JSON, continue checking
327
+ }
328
+ }
329
+
330
+ // SQL detection
331
+ if (/\b(select|insert|update|delete|create|drop|alter|from|where|join|group by|order by|having)\b/i.test(content)) {
332
+ return 'sql';
333
+ }
334
+
335
+ // JavaScript/TypeScript detection
336
+ if (/\b(function|const|let|var|class|import|export|if|else|for|while|return|async|await|=>|console\.log)\b/.test(content) ||
337
+ /\.(js|ts|jsx|tsx|vue|svelte)/.test(content) ||
338
+ /[\{\}]\s*$/.test(content) ||
339
+ /;\s*$/.test(content.split('\n').slice(-1)[0] || '')) {
340
+ return 'javascript';
341
+ }
342
+
343
+ // Python detection
344
+ if (/\b(def|class|import|from|if __name__|print|return)\b/.test(content) ||
345
+ /\.py\b/.test(content) ||
346
+ /:\s*$/.test(content.split('\n')[0] || '')) {
347
+ return 'python';
348
+ }
349
+
350
+ // XML detection
351
+ if (/<\?xml|<\/[\w:]+>/.test(content)) {
352
+ return 'xml';
353
+ }
354
+
355
+ return 'plaintext';
356
+ }
357
+
358
+ // Enhanced code detection patterns
359
+ function detectCodePatterns(content: string): { isCode: boolean; confidence: number; language: string } {
360
+ const lines = content.split('\n');
361
+ let codeScore = 0;
362
+ let totalChecks = 0;
363
+
364
+ // Check for code-like patterns
365
+ const patterns = [
366
+ // Function definitions
367
+ { regex: /\b(function|const|let|var|def|class)\s+\w+/, weight: 3 },
368
+ // Brackets and braces
369
+ { regex: /[{\[\(][^}\]\)]*[}\]\)]/, weight: 1 },
370
+ // Semicolons at end of lines
371
+ { regex: /;\s*$/, weight: 2 },
372
+ // Assignment operators
373
+ { regex: /\w+\s*[=:]\s*/, weight: 1 },
374
+ // Common keywords
375
+ { regex: /\b(if|else|for|while|return|import|export|from|class|function)\b/, weight: 2 },
376
+ // HTML tags
377
+ { regex: /<\/?[\w:]+[^>]*>/, weight: 3 },
378
+ // CSS properties
379
+ { regex: /[\w-]+\s*:\s*[^;]+;/, weight: 3 },
380
+ // Console/print statements
381
+ { regex: /\b(console\.log|print|echo)\s*\(/, weight: 2 },
382
+ // Method calls
383
+ { regex: /\w+\.\w+\s*\(/, weight: 1 },
384
+ // Array/object literals
385
+ { regex: /[\[\{].*[\]\}]/, weight: 1 },
386
+ // Comments
387
+ { regex: /^\s*(\/\/|#|<!--|\/\*|\*)/, weight: 1 },
388
+ // Indentation patterns (2+ spaces or tabs)
389
+ { regex: /^(\s{2,}|\t+)\S/, weight: 1 }
390
+ ];
391
+
392
+ // Analyze each line
393
+ for (const line of lines) {
394
+ for (const pattern of patterns) {
395
+ totalChecks++;
396
+ if (pattern.regex.test(line)) {
397
+ codeScore += pattern.weight;
398
+ }
399
+ }
400
+ }
401
+
402
+ // Additional checks for multi-line patterns
403
+ const fullContent = content.trim();
404
+
405
+ // File extension mentions
406
+ if (/\.(js|ts|jsx|tsx|py|html|css|json|sql|xml|vue|svelte|php|java|cpp|c|go|rs|rb)\b/i.test(fullContent)) {
407
+ codeScore += 3;
408
+ totalChecks += 3;
409
+ }
410
+
411
+ // Code block structure (multiple lines with consistent indentation)
412
+ if (lines.length > 2) {
413
+ const indentedLines = lines.filter(line => /^\s{2,}/.test(line));
414
+ if (indentedLines.length > lines.length * 0.3) {
415
+ codeScore += 2;
416
+ totalChecks += 2;
417
+ }
418
+ }
419
+
420
+ // JSON-like structure
421
+ if (/^\s*[\{\[]/.test(fullContent) && /[\}\]]\s*$/.test(fullContent)) {
422
+ codeScore += 3;
423
+ totalChecks += 3;
424
+ }
425
+
426
+ const confidence = totalChecks > 0 ? (codeScore / totalChecks) : 0;
427
+ const language = detectLanguage(fullContent);
428
+
429
+ // Consider it code if confidence is high enough
430
+ const isCode = confidence > 0.3 || codeScore >= 3;
431
+
432
+ return { isCode, confidence, language };
433
+ }
434
+
435
+ // Code block detection and parsing
436
+ function parseMessageContent(content: string) {
437
+ const parts = [];
438
+
439
+ // First, try to match explicit code blocks (``` syntax)
440
+ const completeCodeBlockRegex = /```(\w+)?\s*([\s\S]*?)```/g;
441
+ let lastIndex = 0;
442
+ let match;
443
+ let foundExplicitBlocks = false;
444
+
445
+ while ((match = completeCodeBlockRegex.exec(content)) !== null) {
446
+ foundExplicitBlocks = true;
447
+
448
+ // Add text before code block
449
+ if (match.index > lastIndex) {
450
+ const textContent = content.slice(lastIndex, match.index).trim();
451
+ if (textContent) {
452
+ parts.push({
453
+ type: 'text',
454
+ content: textContent
455
+ });
456
+ }
457
+ }
458
+
459
+ // Add explicit code block
460
+ parts.push({
461
+ type: 'code',
462
+ language: match[1] || 'plaintext',
463
+ content: match[2].trim()
464
+ });
465
+
466
+ lastIndex = match.index + match[0].length;
467
+ }
468
+
469
+ // If no explicit code blocks found, check for incomplete ones
470
+ if (!foundExplicitBlocks) {
471
+ const incompleteCodeBlockMatch = content.match(/```(\w+)?\s*([\s\S]*)$/);
472
+ if (incompleteCodeBlockMatch) {
473
+ const beforeCodeBlock = content.slice(0, incompleteCodeBlockMatch.index).trim();
474
+ if (beforeCodeBlock) {
475
+ parts.push({
476
+ type: 'text',
477
+ content: beforeCodeBlock
478
+ });
479
+ }
480
+
481
+ parts.push({
482
+ type: 'code',
483
+ language: incompleteCodeBlockMatch[1] || 'plaintext',
484
+ content: incompleteCodeBlockMatch[2].trim()
485
+ });
486
+
487
+ return parts;
488
+ }
489
+ }
490
+
491
+ // Add remaining text after explicit code blocks
492
+ if (lastIndex < content.length) {
493
+ const remainingContent = content.slice(lastIndex).trim();
494
+ if (remainingContent) {
495
+ // If we found explicit blocks, just add remaining as text
496
+ if (foundExplicitBlocks) {
497
+ parts.push({
498
+ type: 'text',
499
+ content: remainingContent
500
+ });
501
+ } else if (enableAutoCodeDetection) {
502
+ // No explicit blocks found — fall back to auto-detection only when debug flag is on
503
+ const detection = detectCodePatterns(remainingContent);
504
+ if (detection.isCode) {
505
+ parts.push({
506
+ type: 'code',
507
+ language: detection.language,
508
+ content: remainingContent
509
+ });
510
+ } else {
511
+ parts.push({
512
+ type: 'text',
513
+ content: remainingContent
514
+ });
515
+ }
516
+ } else {
517
+ parts.push({
518
+ type: 'text',
519
+ content: remainingContent
520
+ });
521
+ }
522
+ }
523
+ }
524
+
525
+ // If no explicit blocks found and no remaining content processed, check entire content
526
+ if (parts.length === 0) {
527
+ if (enableAutoCodeDetection) {
528
+ const detection = detectCodePatterns(content);
529
+ if (detection.isCode) {
530
+ parts.push({
531
+ type: 'code',
532
+ language: detection.language,
533
+ content: content
534
+ });
535
+ } else {
536
+ parts.push({
537
+ type: 'text',
538
+ content: content
539
+ });
540
+ }
541
+ } else {
542
+ parts.push({
543
+ type: 'text',
544
+ content: content
545
+ });
546
+ }
547
+ }
548
+
549
+ return parts;
550
+ }
551
+
552
+ function hasCodeBlocks(content: string): boolean {
553
+ const hasExplicitCodeBlocks = /```[\s\S]*?```/.test(content);
554
+ const hasIncompleteCodeBlocks = /```\w*\s*[\s\S]*$/.test(content) && !/```[\s\S]*?```/.test(content);
555
+
556
+ if (hasExplicitCodeBlocks || hasIncompleteCodeBlocks) return true;
557
+
558
+ // Heuristic auto-detection is opt-in (debug only). Without it, plain prose
559
+ // that happens to mention file paths or extensions stays as text.
560
+ if (!enableAutoCodeDetection) return false;
561
+
562
+ return detectCodePatterns(content).isCode;
563
+ }
564
+ </script>
565
+
566
+ <div
567
+ bind:this={messagesContainer}
568
+ class="{className ? className : 'prompt-chat-messages-ct'} flex flex-col overflow-y-auto p-4 space-y-4"
569
+ class:loading={isLoading}
570
+ style="scroll-behavior: smooth;"
571
+ onscroll={handleScroll}
572
+ >
573
+ <!-- Empty state with suggestions -->
574
+ {#if messages.length === 0 && !isLoading}
575
+ <div class="empty-state-ct flex flex-col items-center justify-center text-center max-w-sm w-full" style="align-self: center;">
576
+ <h3 class="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">💬 Start a conversation</h3>
577
+ <p class="text-gray-600 dark:text-gray-400 text-sm mb-6">
578
+ Ask a question about your {context.type} to get started.
579
+ </p>
580
+
581
+ <!-- Contextual suggestions -->
582
+ <SuggestionButtons
583
+ {context}
584
+ visible={true}
585
+ on:suggestionSelected={handleSuggestionSelected}
586
+ />
587
+ </div>
588
+ {/if}
589
+
590
+ <!-- Conversation turns (user prompt + assistant status grouped) -->
591
+ {#each chatTurns as turn, turnIndex (turn.user?.id ?? turn.replies[0]?.id ?? turnIndex)}
592
+ <div class="wc-chat-turn-ct space-y-1.5 rounded-lg border border-transparent p-0.5"
593
+ class:wc-chat-turn-head={turnHasWidgetHead(turn)}
594
+ >
595
+ {#if turn.user}
596
+ {@const userMsg = turn.user}
597
+ <div class="message-container wc-chat-turn-user ml-auto"
598
+ class:opacity-75={userMsg._optimistic}
599
+ class:opacity-50={userMsg._failed}
600
+ class:has-attachments={userMsg.attachments && userMsg.attachments.length > 0}
601
+ >
602
+ <div
603
+ class="message-bubble relative rounded-lg shadow-sm text-sm leading-relaxed select-text"
604
+ class:p-3={!userMsg.attachments || userMsg.attachments.length === 0}
605
+ class:p-0={userMsg.attachments && userMsg.attachments.length > 0}
606
+ class:bubble-fit-content={userMsg.attachments && userMsg.attachments.length > 0}
607
+ style="background-color: {isCompleted ? MESSAGE_COLORS.USER_COMPLETED : MESSAGE_COLORS.USER_ACTIVE}; color: {MESSAGE_COLORS.USER_TEXT};"
608
+ >
609
+ {#if userMsg.messageContent}
610
+ <p class="whitespace-pre-wrap p-3">{userMsg.messageContent}</p>
611
+ {/if}
612
+ {#if userMsg.attachments && userMsg.attachments.length > 0}
613
+ <div class="message-attachments-container">
614
+ <AttachmentDisplay attachments={userMsg.attachments} variant="message" showPreview={true} />
615
+ </div>
616
+ {:else if userMsg.messageContent && messageImpliesAttachedImage(userMsg.messageContent)}
617
+ <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">
619
+ <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
+ <rect x="3" y="5" width="18" height="14" rx="2" />
621
+ <path d="M4 4l16 16" stroke-linecap="round" />
622
+ </svg>
623
+ </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>
625
+ </div>
626
+ {/if}
627
+ </div>
628
+ </div>
629
+
630
+ {@const commitReply = getTurnCommitReply(turn)}
631
+ {#if commitReply && showCommitMetaOnUserTurn(turn)}
632
+ <div class="wc-user-commit-meta-ct ml-auto flex max-w-[85%] flex-col items-end gap-1">
633
+ {#if commitReply.isWidgetHead}
634
+ <div class="checkpoint-label wc-head-commit-label">
635
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
636
+ <path d="M20 6L9 17l-5-5"/>
637
+ </svg>
638
+ Head at commit
639
+ </div>
640
+ {/if}
641
+ {#if extractCommitShort(commitReply.messageContent || '')}
642
+ <span class="wc-user-commit-sha font-mono text-xs text-gray-500">
643
+ Commit {extractCommitShort(commitReply.messageContent || '')}
644
+ </span>
645
+ {/if}
646
+ {#if turn.user && canUndoHeadUserTurn(turn) && !isLoading}
647
+ <div class="restore-checkpoint-row flex justify-end">
648
+ <button
649
+ class="restore-checkpoint-btn wc-undo-head-btn"
650
+ onclick={() => handleRestoreCheckpoint(turn.user!.id)}
651
+ title="Undo this prompt and revert code to the previous commit"
652
+ >
653
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
654
+ <path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
655
+ <path d="M3 3v5h5"/>
656
+ </svg>
657
+ Undo last change
658
+ </button>
659
+ </div>
660
+ {:else if canRestoreToMessage(commitReply) && !isLoading}
661
+ <div class="restore-checkpoint-row flex justify-end">
662
+ <button
663
+ class="restore-checkpoint-btn"
664
+ onclick={() => handleRestoreCheckpoint(commitReply.id)}
665
+ title="Restore widget code to this prompt's commit"
666
+ >
667
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
668
+ <path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
669
+ <path d="M3 3v5h5"/>
670
+ </svg>
671
+ Restore Checkpoint
672
+ </button>
673
+ </div>
674
+ {/if}
675
+ </div>
676
+ {/if}
677
+ {/if}
678
+
679
+ {#each turn.replies as reply (reply.id)}
680
+ {#if shouldShowAssistantReply(reply)}
681
+ <div class="message-container wc-chat-turn-reply mr-auto"
682
+ class:opacity-75={reply._optimistic}
683
+ class:opacity-50={reply._failed}
684
+ class:current-checkpoint={reply.isWidgetHead && messageHasCommit(reply)}
685
+ >
686
+ <div
687
+ class="message-bubble relative rounded-lg shadow-sm text-sm leading-relaxed select-text p-3"
688
+ class:wc-codegen-failure={isCodegenFailure(reply)}
689
+ style="background-color: {reply.isWidgetHead && messageHasCommit(reply) ? '#e0f2fe' : MESSAGE_COLORS.ASSISTANT}; color: {MESSAGE_COLORS.ASSISTANT_TEXT};{reply.isWidgetHead && messageHasCommit(reply) ? ' border: 1.5px solid #7dd3fc;' : ''}"
690
+ >
691
+ {#if reply.messageContent}
692
+ <p class="whitespace-pre-wrap">{reply.messageContent}</p>
693
+ {/if}
694
+ </div>
695
+
696
+ {#if reply.isWidgetHead && messageHasCommit(reply)}
697
+ <div class="wc-head-commit-meta-ct flex flex-col items-start gap-0.5 mt-1">
698
+ <div class="checkpoint-label wc-head-commit-label">
699
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
700
+ <path d="M20 6L9 17l-5-5"/>
701
+ </svg>
702
+ Head at commit
703
+ </div>
704
+ {#if extractCommitShort(reply.messageContent || '')}
705
+ <span class="wc-assistant-commit-sha font-mono text-xs text-gray-500">
706
+ Commit {extractCommitShort(reply.messageContent || '')}
707
+ </span>
708
+ {/if}
709
+ </div>
710
+ {/if}
711
+
712
+ {#if isCodegenFailure(reply) && turn.user && !isLoading}
713
+ <div class="retry-codegen-row flex justify-start mt-1">
714
+ <button
715
+ class="retry-codegen-btn"
716
+ onclick={() => handleRetryTurn(turn)}
717
+ title="Retry this code generation prompt"
718
+ >
719
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
720
+ <path d="M21 12a9 9 0 1 1-2.64-6.36"/>
721
+ <path d="M21 3v6h-6"/>
722
+ </svg>
723
+ Retry
724
+ </button>
725
+ </div>
726
+ {/if}
727
+
728
+ {#if reply.isWidgetHead && messageHasCommit(reply) && !isLoading}
729
+ <div class="restore-checkpoint-row flex justify-start mt-1">
730
+ <button
731
+ class="restore-checkpoint-btn wc-undo-head-btn"
732
+ onclick={() => handleRestoreCheckpoint(turn.user?.id ?? reply.id)}
733
+ title="Undo this prompt and revert code to the previous commit"
734
+ >
735
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
736
+ <path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
737
+ <path d="M3 3v5h5"/>
738
+ </svg>
739
+ Undo last change
740
+ </button>
741
+ </div>
742
+ {:else if canRestoreToMessage(reply) && !isLoading}
743
+ <div class="restore-checkpoint-row flex justify-start mt-1">
744
+ <button
745
+ class="restore-checkpoint-btn"
746
+ onclick={() => handleRestoreCheckpoint(reply.id)}
747
+ title="Restore widget code to this prompt's commit"
748
+ >
749
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
750
+ <path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/>
751
+ <path d="M3 3v5h5"/>
752
+ </svg>
753
+ Restore Checkpoint
754
+ </button>
755
+ </div>
756
+ {/if}
757
+ </div>
758
+ {/if}
759
+ {/each}
760
+ </div>
761
+ {/each}
762
+
763
+ <!-- Show suggestions toggle (when there are messages) -->
764
+ {#if messages.length > 0 && !isLoading}
765
+ <div class="suggestions-toggle-ct flex justify-center py-2">
766
+ {#if showSuggestionsInline}
767
+ <SuggestionButtons
768
+ {context}
769
+ visible={true}
770
+ onHide={() => { showSuggestionsInline = false; }}
771
+ on:suggestionSelected={(e) => { showSuggestionsInline = false; handleSuggestionSelected(e); }}
772
+ />
773
+ {:else}
774
+ <button
775
+ class="suggestions-toggle-btn text-xs text-gray-400 hover:text-gray-600 px-3 py-1.5 rounded-full border border-gray-200 hover:border-gray-300 bg-white hover:bg-gray-50 transition-colors cursor-pointer"
776
+ onclick={() => { showSuggestionsInline = true; }}
777
+ >
778
+ Show suggestions
779
+ </button>
780
+ {/if}
781
+ </div>
782
+ {/if}
783
+
784
+ <!-- Loading indicator -->
785
+ {#if isLoading}
786
+ <div class="loading-indicator-ct flex items-center justify-start p-3">
787
+ <div class="rounded-lg shadow-sm text-sm leading-relaxed p-3"
788
+ style="background-color: {MESSAGE_COLORS.ASSISTANT}; color: {MESSAGE_COLORS.ASSISTANT_TEXT}"
789
+ >
790
+ <LoadingIndicator
791
+ type="thinking"
792
+ text="Assistant is thinking..."
793
+ color="secondary"
794
+ size="md"
795
+ />
796
+ </div>
797
+ </div>
798
+ {/if}
799
+ </div>
800
+
801
+ <style>
802
+ .loading {
803
+ opacity: 0.75;
804
+ }
805
+
806
+ /* Remove any height constraints from messages */
807
+ .space-y-2 > * {
808
+ max-height: none !important;
809
+ }
810
+
811
+ .message-container {
812
+ max-width: 80%;
813
+ }
814
+
815
+ /* Messages with attachments: text full-width on top, images on a row below */
816
+ .message-container.has-attachments {
817
+ max-width: 90%;
818
+ max-height: none !important;
819
+ }
820
+
821
+ .message-attachments-container {
822
+ margin: 0;
823
+ padding: 8px 12px 12px;
824
+ width: 100%;
825
+ max-height: none !important;
826
+ overflow: visible !important;
827
+ display: flex;
828
+ flex-wrap: wrap;
829
+ gap: 8px;
830
+ }
831
+
832
+ /* Ensure message bubbles don't constrain attachment size */
833
+ .message-container.has-attachments > div {
834
+ max-height: none !important;
835
+ overflow: visible !important;
836
+ }
837
+
838
+ /* Message bubble with attachments — full width for text, images below */
839
+ .bubble-fit-content {
840
+ width: 100% !important;
841
+ }
842
+
843
+ /* Ensure proper alignment for fit-content bubbles */
844
+ .message-container.has-attachments {
845
+ display: flex;
846
+ flex-direction: column;
847
+ }
848
+
849
+ .message-container.has-attachments.ml-auto {
850
+ align-items: flex-end; /* Right align for user messages */
851
+ }
852
+
853
+ .message-container.has-attachments.mr-auto {
854
+ align-items: flex-start; /* Left align for assistant messages */
855
+ }
856
+
857
+ /* Restore checkpoint — always visible below user messages */
858
+ .wc-chat-turn-head {
859
+ border-color: #bae6fd;
860
+ background-color: #f8fcff;
861
+ padding: 6px;
862
+ }
863
+
864
+ .wc-codegen-failure {
865
+ border: 1px solid #fecaca;
866
+ background-color: #fef2f2 !important;
867
+ }
868
+
869
+ .wc-head-commit-label {
870
+ margin-bottom: 0;
871
+ }
872
+
873
+ .wc-head-commit-meta-ct .wc-head-commit-label {
874
+ margin-top: 0;
875
+ }
876
+
877
+ .restore-checkpoint-row {
878
+ margin-top: 4px;
879
+ }
880
+
881
+ .retry-codegen-row {
882
+ margin-top: 4px;
883
+ }
884
+
885
+ .restore-checkpoint-btn,
886
+ .retry-codegen-btn {
887
+ display: inline-flex;
888
+ align-items: center;
889
+ gap: 4px;
890
+ padding: 3px 10px;
891
+ background: transparent;
892
+ color: #888;
893
+ border: 1px solid #ddd;
894
+ border-radius: 12px;
895
+ font-size: 11px;
896
+ font-weight: 500;
897
+ cursor: pointer;
898
+ transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease;
899
+ white-space: nowrap;
900
+ }
901
+
902
+ .restore-checkpoint-btn:hover,
903
+ .retry-codegen-btn:hover {
904
+ background: rgba(0, 0, 0, 0.06);
905
+ color: #555;
906
+ border-color: #bbb;
907
+ }
908
+
909
+ .retry-codegen-btn {
910
+ color: #b91c1c;
911
+ border-color: #fecaca;
912
+ background: #fff7f7;
913
+ }
914
+
915
+ .retry-codegen-btn:hover {
916
+ background: #fee2e2;
917
+ color: #991b1b;
918
+ border-color: #fca5a5;
919
+ }
920
+
921
+ .restore-checkpoint-btn svg,
922
+ .retry-codegen-btn svg {
923
+ flex-shrink: 0;
924
+ }
925
+
926
+ /* Copy message button — top-right, visible on hover */
927
+ .copy-msg-btn {
928
+ position: absolute;
929
+ top: 4px;
930
+ right: 4px;
931
+ display: flex;
932
+ align-items: center;
933
+ justify-content: center;
934
+ width: 22px;
935
+ height: 22px;
936
+ border-radius: 4px;
937
+ border: none;
938
+ background: rgba(0, 0, 0, 0.06);
939
+ color: #888;
940
+ cursor: pointer;
941
+ opacity: 0;
942
+ transition: opacity 0.15s ease;
943
+ }
944
+ .message-bubble:hover .copy-msg-btn {
945
+ opacity: 1;
946
+ }
947
+ .copy-msg-btn:hover {
948
+ background: rgba(0, 0, 0, 0.12);
949
+ color: #555;
950
+ }
951
+
952
+ /* Checkpoint label — shown above the restored message */
953
+ .checkpoint-label {
954
+ display: inline-flex;
955
+ align-items: center;
956
+ gap: 4px;
957
+ padding: 2px 8px;
958
+ background: #0ea5e9;
959
+ color: white;
960
+ border-radius: 8px;
961
+ font-size: 10px;
962
+ font-weight: 600;
963
+ letter-spacing: 0.02em;
964
+ margin-bottom: 4px;
965
+ align-self: flex-end;
966
+ }
967
+
968
+ /* Restored button state */
969
+ .restore-checkpoint-btn.restored {
970
+ opacity: 0.5;
971
+ cursor: default;
972
+ border-color: #7dd3fc;
973
+ color: #0ea5e9;
974
+ }
975
+ .restore-checkpoint-btn.restored:hover {
976
+ background: transparent;
977
+ color: #0ea5e9;
978
+ border-color: #7dd3fc;
979
+ }
980
+
981
+ /* Cap huge messages (legacy full-file dumps) so bubbles stay readable */
982
+ .message-bubble {
983
+ max-width: 100%;
984
+ }
985
+ .message-bubble p,
986
+ .message-content-mixed {
987
+ max-height: 280px;
988
+ overflow-y: auto;
989
+ overflow-x: auto;
990
+ word-break: break-word;
991
+ }
992
+ .message-content-mixed :global(pre) {
993
+ max-height: 240px;
994
+ overflow: auto;
995
+ }
996
+ </style>