@ifc-lite/viewer 1.14.2 → 1.14.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 (80) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/assets/{Arrow.dom-CSgnLhN4.js → Arrow.dom-_vGzMMKs.js} +1 -1
  3. package/dist/assets/basketViewActivator-BZcoCL3V.js +1 -0
  4. package/dist/assets/{browser-qSKWrKQW.js → browser-Czmf34bo.js} +1 -1
  5. package/dist/assets/ifc-lite_bg-DyBKoGgk.wasm +0 -0
  6. package/dist/assets/index-CMQ_Dgkr.css +1 -0
  7. package/dist/assets/index-D7nEDctQ.js +229 -0
  8. package/dist/assets/{index-4Y4XaV8N.js → index-DX-Qf5fA.js} +72669 -61673
  9. package/dist/assets/{native-bridge-CSFDsEkg.js → native-bridge-DAOWftxE.js} +1 -1
  10. package/dist/assets/{wasm-bridge-Zf90ysEm.js → wasm-bridge-D7jYpn8a.js} +1 -1
  11. package/dist/index.html +2 -2
  12. package/package.json +21 -20
  13. package/src/App.tsx +17 -1
  14. package/src/components/viewer/BasketPresentationDock.tsx +8 -4
  15. package/src/components/viewer/ChatPanel.tsx +1402 -0
  16. package/src/components/viewer/CodeEditor.tsx +70 -4
  17. package/src/components/viewer/CommandPalette.tsx +1 -0
  18. package/src/components/viewer/HierarchyPanel.tsx +28 -13
  19. package/src/components/viewer/MainToolbar.tsx +113 -95
  20. package/src/components/viewer/ScriptPanel.tsx +351 -184
  21. package/src/components/viewer/UpgradePage.tsx +69 -0
  22. package/src/components/viewer/Viewport.tsx +23 -0
  23. package/src/components/viewer/chat/ChatMessage.tsx +144 -0
  24. package/src/components/viewer/chat/ExecutableCodeBlock.tsx +416 -0
  25. package/src/components/viewer/chat/ModelSelector.tsx +102 -0
  26. package/src/components/viewer/chat/renderTextContent.test.ts +23 -0
  27. package/src/components/viewer/chat/renderTextContent.ts +19 -0
  28. package/src/components/viewer/hierarchy/HierarchyNode.tsx +10 -3
  29. package/src/components/viewer/hierarchy/treeDataBuilder.test.ts +126 -0
  30. package/src/components/viewer/hierarchy/treeDataBuilder.ts +139 -38
  31. package/src/components/viewer/hierarchy/types.ts +6 -1
  32. package/src/components/viewer/hierarchy/useHierarchyTree.ts +27 -12
  33. package/src/hooks/useIfcCache.ts +1 -2
  34. package/src/hooks/useSandbox.ts +122 -6
  35. package/src/index.css +10 -0
  36. package/src/lib/attachments.ts +46 -0
  37. package/src/lib/llm/ClerkChatSync.tsx +74 -0
  38. package/src/lib/llm/clerk-auth.ts +62 -0
  39. package/src/lib/llm/code-extractor.ts +50 -0
  40. package/src/lib/llm/context-builder.test.ts +18 -0
  41. package/src/lib/llm/context-builder.ts +305 -0
  42. package/src/lib/llm/free-models.test.ts +118 -0
  43. package/src/lib/llm/message-capabilities.test.ts +131 -0
  44. package/src/lib/llm/message-capabilities.ts +94 -0
  45. package/src/lib/llm/models.ts +197 -0
  46. package/src/lib/llm/repair-loop.test.ts +91 -0
  47. package/src/lib/llm/repair-loop.ts +76 -0
  48. package/src/lib/llm/script-diagnostics.ts +445 -0
  49. package/src/lib/llm/script-edit-ops.test.ts +399 -0
  50. package/src/lib/llm/script-edit-ops.ts +954 -0
  51. package/src/lib/llm/script-preflight.test.ts +513 -0
  52. package/src/lib/llm/script-preflight.ts +990 -0
  53. package/src/lib/llm/script-preservation.test.ts +128 -0
  54. package/src/lib/llm/script-preservation.ts +152 -0
  55. package/src/lib/llm/stream-client.test.ts +97 -0
  56. package/src/lib/llm/stream-client.ts +410 -0
  57. package/src/lib/llm/system-prompt.test.ts +181 -0
  58. package/src/lib/llm/system-prompt.ts +665 -0
  59. package/src/lib/llm/types.ts +150 -0
  60. package/src/lib/scripts/templates/bim-globals.d.ts +226 -7
  61. package/src/lib/scripts/templates/create-building.ts +12 -12
  62. package/src/main.tsx +10 -1
  63. package/src/sdk/adapters/export-adapter.test.ts +24 -0
  64. package/src/sdk/adapters/export-adapter.ts +40 -16
  65. package/src/sdk/adapters/files-adapter.ts +39 -0
  66. package/src/sdk/adapters/model-compat.ts +1 -1
  67. package/src/sdk/adapters/mutate-adapter.ts +20 -6
  68. package/src/sdk/adapters/mutation-view.ts +112 -0
  69. package/src/sdk/adapters/query-adapter.ts +100 -4
  70. package/src/sdk/local-backend.ts +4 -0
  71. package/src/store/index.ts +15 -1
  72. package/src/store/slices/chatSlice.test.ts +325 -0
  73. package/src/store/slices/chatSlice.ts +468 -0
  74. package/src/store/slices/scriptSlice.test.ts +75 -0
  75. package/src/store/slices/scriptSlice.ts +256 -9
  76. package/src/vite-env.d.ts +10 -0
  77. package/vite.config.ts +21 -2
  78. package/dist/assets/ifc-lite_bg-BOvNXJA_.wasm +0 -0
  79. package/dist/assets/index-ByrFvN5A.css +0 -1
  80. package/dist/assets/index-CN7qDq7G.js +0 -216
@@ -0,0 +1,1402 @@
1
+ /* This Source Code Form is subject to the terms of the Mozilla Public
2
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4
+
5
+ /**
6
+ * ChatPanel — Interactive LLM chat with live 3D model generation.
7
+ *
8
+ * Features:
9
+ * - Streaming responses with blinking cursor
10
+ * - Executable code blocks with "Run" and "Fix this" buttons
11
+ * - Drag-and-drop file upload with visual dropzone
12
+ * - Smart auto-scroll with "scroll to bottom" button
13
+ * - Clickable example prompts in empty state
14
+ * - Auto-execute toggle for hands-free workflow
15
+ * - Keyboard shortcuts (Cmd+L focus, Escape close)
16
+ * - Conversation persistence via localStorage
17
+ * - Clear confirmation dialog
18
+ * - Error-to-LLM feedback loop for failed scripts
19
+ */
20
+
21
+ import { useCallback, useRef, useEffect, useState, type KeyboardEvent, type DragEvent } from 'react';
22
+ import {
23
+ X,
24
+ Send,
25
+ Square,
26
+ Trash2,
27
+ Paperclip,
28
+ Loader2,
29
+ ArrowDown,
30
+ Zap,
31
+ } from 'lucide-react';
32
+ import { SignInButton, SignedIn, SignedOut, UserButton } from '@clerk/clerk-react';
33
+ import { Button } from '@/components/ui/button';
34
+ import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
35
+ import { useViewerStore } from '@/store';
36
+ import { buildErrorFeedbackContent } from '@/store/slices/chatSlice';
37
+ import { ChatMessageComponent } from './chat/ChatMessage';
38
+ import { ModelSelector } from './chat/ModelSelector';
39
+ import { fetchUsageSnapshot, streamChat, type StreamMessage, type TextContentPart, type ImageContentPart, type UsageInfo } from '@/lib/llm/stream-client';
40
+ import { buildStreamMessagesForModel, filterAttachmentsForModel } from '@/lib/llm/message-capabilities';
41
+ import { buildSystemPrompt } from '@/lib/llm/system-prompt';
42
+ import { getModelContext, parseCSV } from '@/lib/llm/context-builder';
43
+ import { collectActiveFileAttachments } from '@/lib/attachments';
44
+ import { extractCodeBlocks } from '@/lib/llm/code-extractor';
45
+ import { extractScriptEditOps, filterUnappliedScriptOps } from '@/lib/llm/script-edit-ops';
46
+ import { createPatchDiagnostic, getPrimaryRootCause, type RepairScope } from '@/lib/llm/script-diagnostics';
47
+ import type { ScriptDiagnostic } from '@/lib/llm/script-diagnostics';
48
+ import { buildRepairSessionKey, getEscalatedRepairScope, pruneMessagesForRepair } from '@/lib/llm/repair-loop';
49
+ import type { ChatMessage, ChatRepairRequest, FileAttachment } from '@/lib/llm/types';
50
+ import { canUsePlainCodeBlockFallback, type ScriptMutationIntent } from '@/lib/llm/script-preservation';
51
+ import { Image as ImageIcon } from 'lucide-react';
52
+ import { isClerkConfigured } from '@/lib/llm/clerk-auth';
53
+ import { getModelById } from '@/lib/llm/models';
54
+ import { useSandbox } from '@/hooks/useSandbox';
55
+
56
+ // Environment variable for the proxy URL
57
+ const PROXY_URL = import.meta.env.VITE_LLM_PROXY_URL as string || '/api/chat';
58
+
59
+ const EXAMPLE_PROMPTS = [
60
+ 'Create a 3-story house with walls, slabs, and a gable roof',
61
+ 'Color all IfcWalls by their fire rating',
62
+ 'Export a quantity takeoff as CSV',
63
+ 'Create a skyscraper with 4x4 column grid, 30x40m, concrete shaft',
64
+ ];
65
+
66
+ const CONTINUE_PROMPT = 'Continue from exactly where your last response stopped. Do not repeat previously generated text.';
67
+ const DEFAULT_PRO_MONTHLY_CREDIT_LIMIT = 1000;
68
+ const USAGE_REFRESH_INTERVAL_MS = 15_000;
69
+ const EST_CHARS_PER_TOKEN = 4;
70
+ const IMAGE_TOKEN_COST_EST = 850;
71
+ const INPUT_BUDGET_RATIO = 0.72;
72
+ const OUTPUT_TOKEN_RESERVE = 9_000;
73
+ const MIN_INPUT_BUDGET = 8_000;
74
+ const MAX_RECENT_MESSAGES = 48;
75
+ const SUMMARY_SNIPPET_LEN = 240;
76
+ const MAX_INLINE_IMAGE_DATA_URL_CHARS = 1_200_000;
77
+ const MAX_ATTACHMENTS_PER_MESSAGE = 6;
78
+ const MAX_TEXT_ATTACHMENT_BYTES = 512_000;
79
+ const MAX_IMAGE_ATTACHMENT_BYTES = 8_000_000;
80
+
81
+ function createAttachmentId(): string {
82
+ return crypto.randomUUID();
83
+ }
84
+
85
+ interface ChatSendOptions {
86
+ continuationBase?: string;
87
+ intent?: ScriptMutationIntent;
88
+ repairDiagnostics?: ScriptDiagnostic[];
89
+ requestedRepairScope?: RepairScope;
90
+ rootCauseKey?: string;
91
+ }
92
+
93
+ /** Convert a File to a base64 data URL */
94
+ function fileToBase64(file: File): Promise<string> {
95
+ return new Promise((resolve, reject) => {
96
+ const reader = new FileReader();
97
+ reader.onload = () => resolve(reader.result as string);
98
+ reader.onerror = reject;
99
+ reader.readAsDataURL(file);
100
+ });
101
+ }
102
+
103
+ async function imageFileToCompressedBase64(file: File): Promise<string> {
104
+ const raw = await fileToBase64(file);
105
+ return compressDataUrlImage(raw);
106
+ }
107
+
108
+ function compressDataUrlImage(dataUrl: string): Promise<string> {
109
+ return new Promise((resolve) => {
110
+ const img = new Image();
111
+ img.onload = () => {
112
+ const maxSide = 1400;
113
+ const srcW = img.naturalWidth || img.width;
114
+ const srcH = img.naturalHeight || img.height;
115
+ const scale = Math.min(1, maxSide / Math.max(srcW, srcH));
116
+ const outW = Math.max(1, Math.round(srcW * scale));
117
+ const outH = Math.max(1, Math.round(srcH * scale));
118
+ const canvas = document.createElement('canvas');
119
+ canvas.width = outW;
120
+ canvas.height = outH;
121
+ const ctx = canvas.getContext('2d');
122
+ if (!ctx) {
123
+ resolve(dataUrl);
124
+ return;
125
+ }
126
+ ctx.drawImage(img, 0, 0, outW, outH);
127
+ resolve(canvas.toDataURL('image/jpeg', 0.72));
128
+ };
129
+ img.onerror = () => resolve(dataUrl);
130
+ img.src = dataUrl;
131
+ });
132
+ }
133
+
134
+ function stripContinuationOverlap(previous: string, continuation: string): string {
135
+ const prev = previous.trimEnd();
136
+ const next = continuation.trimStart();
137
+ if (!prev || !next) return continuation;
138
+
139
+ const maxOverlap = Math.min(prev.length, next.length, 1200);
140
+ const minOverlap = Math.min(48, maxOverlap);
141
+ for (let size = maxOverlap; size >= minOverlap; size--) {
142
+ const suffix = prev.slice(-size);
143
+ const prefix = next.slice(0, size);
144
+ if (suffix === prefix) {
145
+ return next.slice(size).trimStart();
146
+ }
147
+ }
148
+ return continuation;
149
+ }
150
+
151
+ function estimateTextTokens(text: string): number {
152
+ return Math.ceil(text.length / EST_CHARS_PER_TOKEN);
153
+ }
154
+
155
+ function estimateContentTokens(content: string | Array<TextContentPart | ImageContentPart>): number {
156
+ if (typeof content === 'string') return estimateTextTokens(content);
157
+ let tokens = 0;
158
+ for (const part of content) {
159
+ if (part.type === 'text') {
160
+ tokens += estimateTextTokens(part.text);
161
+ } else {
162
+ tokens += IMAGE_TOKEN_COST_EST;
163
+ }
164
+ }
165
+ return tokens;
166
+ }
167
+
168
+ function estimateMessagesTokens(messages: Array<{ role: string; content: string | Array<TextContentPart | ImageContentPart> }>): number {
169
+ return messages.reduce((sum, m) => sum + estimateContentTokens(m.content) + 8, 0);
170
+ }
171
+
172
+ function summarizeDroppedMessages(messages: ChatMessage[]): string {
173
+ if (messages.length === 0) return '';
174
+ const summaryParts: string[] = [];
175
+ for (const m of messages.slice(-14)) {
176
+ const body = m.content.replace(/\s+/g, ' ').trim().slice(0, SUMMARY_SNIPPET_LEN);
177
+ if (!body) continue;
178
+ summaryParts.push(`${m.role}: ${body}`);
179
+ }
180
+ return summaryParts.join('\n');
181
+ }
182
+
183
+ interface ChatPanelProps {
184
+ onClose?: () => void;
185
+ }
186
+
187
+ export function ChatPanel({ onClose }: ChatPanelProps) {
188
+ const messages = useViewerStore((s) => s.chatMessages);
189
+ const status = useViewerStore((s) => s.chatStatus);
190
+ const streamingContent = useViewerStore((s) => s.chatStreamingContent);
191
+ const activeModel = useViewerStore((s) => s.chatActiveModel);
192
+ const autoExecute = useViewerStore((s) => s.chatAutoExecute);
193
+ const error = useViewerStore((s) => s.chatError);
194
+ const attachments = useViewerStore((s) => s.chatAttachments);
195
+ const addMessage = useViewerStore((s) => s.addChatMessage);
196
+ const setChatStatus = useViewerStore((s) => s.setChatStatus);
197
+ const updateStreaming = useViewerStore((s) => s.updateLastAssistantMessage);
198
+ const finalizeAssistant = useViewerStore((s) => s.finalizeAssistantMessage);
199
+ const setChatError = useViewerStore((s) => s.setChatError);
200
+ const setChatAbortController = useViewerStore((s) => s.setChatAbortController);
201
+ const setAutoExecute = useViewerStore((s) => s.setChatAutoExecute);
202
+ const addAttachment = useViewerStore((s) => s.addChatAttachment);
203
+ const removeAttachment = useViewerStore((s) => s.removeChatAttachment);
204
+ const clearAttachments = useViewerStore((s) => s.clearChatAttachments);
205
+ const clearMessages = useViewerStore((s) => s.clearChatMessages);
206
+ const resetScriptEditorForNewChat = useViewerStore((s) => s.resetScriptEditorForNewChat);
207
+ const pendingPrompt = useViewerStore((s) => s.chatPendingPrompt);
208
+ const consumePendingPrompt = useViewerStore((s) => s.consumeChatPendingPrompt);
209
+ const pendingRepairRequest = useViewerStore((s) => s.chatPendingRepairRequest);
210
+ const consumePendingRepairRequest = useViewerStore((s) => s.consumeChatPendingRepairRequest);
211
+ const authToken = useViewerStore((s) => s.chatAuthToken);
212
+ const hasPro = useViewerStore((s) => s.chatHasPro);
213
+ const usage = useViewerStore((s) => s.chatUsage);
214
+ const setChatUsage = useViewerStore((s) => s.setChatUsage);
215
+ const { execute } = useSandbox();
216
+ const displayUsage: UsageInfo | null = usage ?? (hasPro
217
+ ? {
218
+ type: 'credits',
219
+ used: 0,
220
+ limit: DEFAULT_PRO_MONTHLY_CREDIT_LIMIT,
221
+ pct: 0,
222
+ resetAt: 0,
223
+ billable: false,
224
+ }
225
+ : null);
226
+ const usageResetLabel = displayUsage?.resetAt && displayUsage.resetAt > 0
227
+ ? new Date(displayUsage.resetAt * 1000).toLocaleDateString()
228
+ : '—';
229
+
230
+ const [inputText, setInputText] = useState('');
231
+ const [showClearConfirm, setShowClearConfirm] = useState(false);
232
+ const [isDragging, setIsDragging] = useState(false);
233
+ const [showScrollBtn, setShowScrollBtn] = useState(false);
234
+ const [userScrolledUp, setUserScrolledUp] = useState(false);
235
+ const [lastFinishReason, setLastFinishReason] = useState<string | null>(null);
236
+
237
+ const inputRef = useRef<HTMLTextAreaElement>(null);
238
+ const scrollRef = useRef<HTMLDivElement>(null);
239
+ const fileInputRef = useRef<HTMLInputElement>(null);
240
+ const dragCounterRef = useRef(0);
241
+ const autoRepairAttemptCountsRef = useRef(new Map<string, { attempts: number; lastScope: RepairScope }>());
242
+
243
+ const resizeInput = useCallback(() => {
244
+ const target = inputRef.current;
245
+ if (!target) return;
246
+ target.style.height = 'auto';
247
+ target.style.height = `${Math.min(target.scrollHeight, 120)}px`;
248
+ }, []);
249
+
250
+ useEffect(() => {
251
+ resizeInput();
252
+ }, [inputText, resizeInput]);
253
+
254
+ // ── Smart auto-scroll ──
255
+ // Only auto-scroll if user hasn't scrolled up to read old messages
256
+ useEffect(() => {
257
+ const el = scrollRef.current;
258
+ if (!el) return;
259
+ if (!userScrolledUp) {
260
+ el.scrollTop = el.scrollHeight;
261
+ }
262
+ }, [messages.length, streamingContent, userScrolledUp]);
263
+
264
+ // Detect whether user has scrolled up from the bottom
265
+ useEffect(() => {
266
+ const el = scrollRef.current;
267
+ if (!el) return;
268
+
269
+ const handleScroll = () => {
270
+ const isNearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 60;
271
+ setUserScrolledUp(!isNearBottom);
272
+ setShowScrollBtn(!isNearBottom && (messages.length > 0 || !!streamingContent));
273
+ };
274
+
275
+ el.addEventListener('scroll', handleScroll, { passive: true });
276
+ return () => el.removeEventListener('scroll', handleScroll);
277
+ }, [messages.length, streamingContent]);
278
+
279
+ const scrollToBottom = useCallback(() => {
280
+ const el = scrollRef.current;
281
+ if (el) {
282
+ el.scrollTop = el.scrollHeight;
283
+ setUserScrolledUp(false);
284
+ setShowScrollBtn(false);
285
+ }
286
+ }, []);
287
+
288
+ // Focus input on mount
289
+ useEffect(() => {
290
+ inputRef.current?.focus();
291
+ }, []);
292
+
293
+ // Keep usage meter hydrated even before first prompt and refresh periodically.
294
+ useEffect(() => {
295
+ let cancelled = false;
296
+ const refreshUsage = async () => {
297
+ const snapshot = await fetchUsageSnapshot(PROXY_URL, authToken);
298
+ if (!cancelled && snapshot) {
299
+ setChatUsage(snapshot);
300
+ }
301
+ };
302
+
303
+ void refreshUsage();
304
+ const timer = window.setInterval(() => {
305
+ void refreshUsage();
306
+ }, USAGE_REFRESH_INTERVAL_MS);
307
+
308
+ return () => {
309
+ cancelled = true;
310
+ window.clearInterval(timer);
311
+ };
312
+ }, [authToken, setChatUsage]);
313
+
314
+ // ── Keyboard shortcuts ──
315
+ useEffect(() => {
316
+ const handler = (e: globalThis.KeyboardEvent) => {
317
+ // Cmd+L / Ctrl+L → focus chat input
318
+ if ((e.ctrlKey || e.metaKey) && e.key === 'l') {
319
+ e.preventDefault();
320
+ inputRef.current?.focus();
321
+ }
322
+ // Escape → close panel (only if chat input isn't focused or is empty)
323
+ if (e.key === 'Escape' && onClose) {
324
+ const isChatFocused = document.activeElement === inputRef.current;
325
+ if (!isChatFocused || !inputText) {
326
+ onClose();
327
+ }
328
+ }
329
+ };
330
+ window.addEventListener('keydown', handler);
331
+ return () => window.removeEventListener('keydown', handler);
332
+ }, [onClose, inputText]);
333
+
334
+ const buildRepairPromptFromLiveState = useCallback((request: ChatRepairRequest) => {
335
+ const state = useViewerStore.getState();
336
+ return buildErrorFeedbackContent(state.scriptEditorContent, request.error, {
337
+ diagnostics: request.diagnostics ?? state.scriptLastDiagnostics,
338
+ currentRevision: state.scriptEditorRevision,
339
+ currentSelection: request.includeSelection ? state.scriptEditorSelection : undefined,
340
+ staleCodeBlock: request.staleCodeBlock,
341
+ reason: request.reason,
342
+ requestedRepairScope: request.requestedRepairScope,
343
+ });
344
+ }, []);
345
+
346
+ const triggerAutoRepair = (request: ChatRepairRequest) => {
347
+ const state = useViewerStore.getState();
348
+ const diagnostics = request.diagnostics ?? state.scriptLastDiagnostics;
349
+ const primaryRootCause = getPrimaryRootCause(diagnostics);
350
+ const sessionKey = buildRepairSessionKey({
351
+ diagnostics,
352
+ currentCode: state.scriptEditorContent,
353
+ });
354
+ const sessionState = autoRepairAttemptCountsRef.current.get(sessionKey);
355
+ const defaultScope = request.requestedRepairScope ?? primaryRootCause?.repairScope ?? 'local';
356
+ const requestedScope = sessionState
357
+ ? sessionState.attempts >= 1 && sessionState.lastScope === defaultScope
358
+ ? getEscalatedRepairScope(defaultScope) ?? null
359
+ : defaultScope
360
+ : defaultScope;
361
+
362
+ if (!requestedScope) {
363
+ setChatError('Auto-repair stopped after the same root cause persisted through escalation. Use Fix with LLM after adjusting the script or make a broader manual change.');
364
+ return;
365
+ }
366
+
367
+ autoRepairAttemptCountsRef.current.set(sessionKey, {
368
+ attempts: (sessionState?.attempts ?? 0) + 1,
369
+ lastScope: requestedScope,
370
+ });
371
+
372
+ void doSend(buildRepairPromptFromLiveState({
373
+ ...request,
374
+ diagnostics,
375
+ requestedRepairScope: requestedScope,
376
+ rootCauseKey: primaryRootCause?.rootCauseKey,
377
+ }), {
378
+ intent: 'repair',
379
+ repairDiagnostics: diagnostics,
380
+ requestedRepairScope: requestedScope,
381
+ rootCauseKey: primaryRootCause?.rootCauseKey,
382
+ });
383
+ };
384
+
385
+ // ── Core send logic ──
386
+ const doSend = useCallback(async (text: string, options?: ChatSendOptions) => {
387
+ if (!text.trim() || status === 'streaming' || status === 'sending') return;
388
+
389
+ const continuationBase = options?.continuationBase;
390
+ const responseIntent = options?.intent ?? 'create';
391
+ if (responseIntent !== 'repair') {
392
+ autoRepairAttemptCountsRef.current.clear();
393
+ }
394
+ const liveState = useViewerStore.getState();
395
+ const currentMessages = responseIntent === 'repair'
396
+ ? pruneMessagesForRepair(liveState.chatMessages)
397
+ : liveState.chatMessages;
398
+ const liveScriptContext = {
399
+ content: liveState.scriptEditorContent,
400
+ revision: liveState.scriptEditorRevision,
401
+ selection: liveState.scriptEditorSelection,
402
+ };
403
+ const liveDiagnostics = liveState.scriptLastDiagnostics;
404
+ const effectiveDiagnostics = options?.repairDiagnostics ?? liveDiagnostics;
405
+ const primaryRootCause = options?.rootCauseKey
406
+ ? { rootCauseKey: options.rootCauseKey, repairScope: options.requestedRepairScope ?? 'local' }
407
+ : getPrimaryRootCause(effectiveDiagnostics);
408
+ setLastFinishReason(null);
409
+
410
+ const activeModelInfo = getModelById(activeModel);
411
+ const supportsImages = activeModelInfo?.supportsImages ?? false;
412
+ const supportsFileAttachments = activeModelInfo?.supportsFileAttachments ?? true;
413
+ const filtered = filterAttachmentsForModel(attachments, supportsImages, supportsFileAttachments);
414
+ const droppedAttachmentWarnings: string[] = [];
415
+ if (filtered.droppedImages > 0) {
416
+ droppedAttachmentWarnings.push('image attachments');
417
+ }
418
+ if (filtered.droppedFiles > 0) {
419
+ droppedAttachmentWarnings.push('file attachments');
420
+ }
421
+ if (droppedAttachmentWarnings.length > 0) {
422
+ setChatError(
423
+ `Selected model does not support ${droppedAttachmentWarnings.join(' and ')}. Unsupported attachments were skipped.`,
424
+ );
425
+ }
426
+
427
+ const userMessage: ChatMessage = {
428
+ id: crypto.randomUUID(),
429
+ role: 'user',
430
+ content: text.trim(),
431
+ createdAt: Date.now(),
432
+ attachments: filtered.accepted.length > 0 ? [...filtered.accepted] : undefined,
433
+ };
434
+ addMessage(userMessage);
435
+ setInputText('');
436
+ setChatStatus('sending');
437
+ setUserScrolledUp(false);
438
+
439
+ // Reset textarea height
440
+ resizeInput();
441
+
442
+ // Check for auto-captured viewport screenshot to include
443
+ const pendingViewportScreenshot = useViewerStore.getState().chatViewportScreenshot;
444
+ if (pendingViewportScreenshot) {
445
+ useViewerStore.getState().setChatViewportScreenshot(null);
446
+ }
447
+ let viewportScreenshot: string | null = null;
448
+ if (pendingViewportScreenshot && supportsImages) {
449
+ // Normalize legacy/uncompressed screenshots before attaching.
450
+ const normalized = await compressDataUrlImage(pendingViewportScreenshot);
451
+ if (normalized.length <= MAX_INLINE_IMAGE_DATA_URL_CHARS) {
452
+ viewportScreenshot = normalized;
453
+ } else {
454
+ setChatError('Auto-captured screenshot was too large and was skipped.');
455
+ }
456
+ }
457
+
458
+ const allMessages = [...currentMessages, userMessage];
459
+
460
+ const modelContext = getModelContext();
461
+ const fileAttachments = supportsFileAttachments
462
+ ? collectActiveFileAttachments(allMessages, filtered.accepted)
463
+ : [];
464
+ const systemPrompt = buildSystemPrompt(modelContext, fileAttachments, {
465
+ content: liveScriptContext.content,
466
+ revision: liveScriptContext.revision,
467
+ selection: liveScriptContext.selection,
468
+ }, {
469
+ userPrompt: text.trim(),
470
+ diagnostics: effectiveDiagnostics,
471
+ });
472
+ const contextWindow = activeModelInfo?.contextWindow ?? 128_000;
473
+ const inputBudget = Math.max(
474
+ MIN_INPUT_BUDGET,
475
+ Math.floor(contextWindow * INPUT_BUDGET_RATIO) - OUTPUT_TOKEN_RESERVE,
476
+ );
477
+
478
+ let compactedMessages = [...allMessages];
479
+ let droppedMessages: ChatMessage[] = [];
480
+ let streamBuild = buildStreamMessagesForModel(compactedMessages, viewportScreenshot, supportsImages);
481
+ let streamMessages = streamBuild.messages;
482
+ let estimatedInputTokens = estimateTextTokens(systemPrompt) + estimateMessagesTokens(streamMessages);
483
+
484
+ while (estimatedInputTokens > inputBudget && compactedMessages.length > 2) {
485
+ const dropCount = Math.min(4, Math.max(1, compactedMessages.length - 2));
486
+ droppedMessages = [...droppedMessages, ...compactedMessages.slice(0, dropCount)];
487
+ compactedMessages = compactedMessages.slice(dropCount);
488
+ if (compactedMessages.length > MAX_RECENT_MESSAGES) {
489
+ const over = compactedMessages.length - MAX_RECENT_MESSAGES;
490
+ droppedMessages = [...droppedMessages, ...compactedMessages.slice(0, over)];
491
+ compactedMessages = compactedMessages.slice(over);
492
+ }
493
+ streamBuild = buildStreamMessagesForModel(compactedMessages, viewportScreenshot, supportsImages);
494
+ streamMessages = streamBuild.messages;
495
+ estimatedInputTokens = estimateTextTokens(systemPrompt) + estimateMessagesTokens(streamMessages);
496
+ }
497
+
498
+ if (streamBuild.droppedInlineImages > 0 || streamBuild.droppedViewportScreenshot) {
499
+ setChatError(
500
+ 'Selected model does not support image input. Screenshot/image payload was omitted.',
501
+ );
502
+ }
503
+
504
+ if (droppedMessages.length > 0) {
505
+ const summary = summarizeDroppedMessages(droppedMessages);
506
+ if (summary) {
507
+ const summaryMessage = {
508
+ role: 'system' as const,
509
+ content: `Conversation summary of earlier turns (for continuity only):\n${summary}`,
510
+ };
511
+ streamMessages = [summaryMessage, ...streamMessages];
512
+ estimatedInputTokens = estimateTextTokens(systemPrompt) + estimateMessagesTokens(streamMessages);
513
+ }
514
+ }
515
+
516
+ if (estimatedInputTokens > inputBudget && import.meta.env.DEV) {
517
+ console.info('[llm-budget]', {
518
+ model: activeModel,
519
+ contextWindow,
520
+ inputBudget,
521
+ estimatedInputTokens,
522
+ droppedMessages: droppedMessages.length,
523
+ });
524
+ }
525
+
526
+ const abortController = new AbortController();
527
+ setChatAbortController(abortController);
528
+ useViewerStore.getState().beginAssistantScriptTurn();
529
+
530
+ let accumulated = '';
531
+ const responseBaseRevision = liveScriptContext.revision;
532
+ const responseBaseContent = liveScriptContext.content;
533
+ const editParseOptions = {
534
+ baseRevision: responseBaseRevision,
535
+ baseContent: responseBaseContent,
536
+ intent: responseIntent,
537
+ requestedRepairScope: options?.requestedRepairScope ?? primaryRootCause?.repairScope,
538
+ targetRootCause: options?.rootCauseKey ?? primaryRootCause?.rootCauseKey,
539
+ } as const;
540
+ const responseEditState = {
541
+ intent: responseIntent,
542
+ appliedOpIds: new Set<string>(),
543
+ acceptedOps: [] as ReturnType<typeof extractScriptEditOps>['operations'],
544
+ appliedAny: false,
545
+ applyFailed: false,
546
+ fallbackApplied: false,
547
+ rolledBack: false,
548
+ applyFailureStatus: null as null | 'revision_conflict' | 'range_error' | 'semantic_error' | 'parse_error',
549
+ applyFailureError: null as string | null,
550
+ applyFailureDiagnostic: null as ReturnType<typeof useViewerStore.getState>['scriptLastDiagnostics'][number] | null,
551
+ };
552
+ let pendingAttachmentsCleared = attachments.length === 0;
553
+
554
+ const clearPendingAttachmentsOnce = () => {
555
+ if (pendingAttachmentsCleared) return;
556
+ clearAttachments();
557
+ pendingAttachmentsCleared = true;
558
+ };
559
+
560
+ const rollbackAssistantTurnIfNeeded = () => {
561
+ if (responseEditState.rolledBack || !responseEditState.appliedAny) return;
562
+ useViewerStore.getState().rollbackAssistantScriptTurn();
563
+ responseEditState.appliedAny = false;
564
+ responseEditState.fallbackApplied = false;
565
+ responseEditState.rolledBack = true;
566
+ };
567
+
568
+ const commitAssistantTurn = () => {
569
+ if (!responseEditState.rolledBack) {
570
+ useViewerStore.getState().commitAssistantScriptTurn();
571
+ }
572
+ };
573
+
574
+ await streamChat({
575
+ proxyUrl: PROXY_URL,
576
+ model: activeModel,
577
+ messages: streamMessages,
578
+ system: systemPrompt,
579
+ authToken,
580
+ signal: abortController.signal,
581
+ onChunk: (chunk) => {
582
+ clearPendingAttachmentsOnce();
583
+ accumulated += chunk;
584
+ if (!responseEditState.applyFailed && responseEditState.intent !== 'repair') {
585
+ const parsed = extractScriptEditOps(accumulated, editParseOptions);
586
+ const freshOps = filterUnappliedScriptOps(parsed.operations, responseEditState.appliedOpIds);
587
+ if (freshOps.length > 0) {
588
+ const applyResult = useViewerStore.getState().applyScriptEditOps(freshOps, {
589
+ acceptedBaseRevision: responseBaseRevision,
590
+ baseContentSnapshot: responseBaseContent,
591
+ priorAcceptedOps: responseEditState.acceptedOps,
592
+ intent: responseEditState.intent,
593
+ });
594
+ if (applyResult.ok) {
595
+ applyResult.appliedOpIds.forEach((id) => responseEditState.appliedOpIds.add(id));
596
+ responseEditState.acceptedOps.push(...freshOps);
597
+ responseEditState.appliedAny = true;
598
+ useViewerStore.getState().setScriptPanelVisible(true);
599
+ } else {
600
+ rollbackAssistantTurnIfNeeded();
601
+ responseEditState.applyFailed = true;
602
+ responseEditState.applyFailureStatus = applyResult.status === 'ok' ? 'semantic_error' : (applyResult.status ?? 'semantic_error');
603
+ responseEditState.applyFailureError = applyResult.error ?? 'unknown error';
604
+ responseEditState.applyFailureDiagnostic = applyResult.diagnostic ?? null;
605
+ setChatError(
606
+ applyResult.status === 'revision_conflict'
607
+ ? `Incremental edit apply hit a revision conflict: ${applyResult.error ?? 'unknown error'}`
608
+ : `Incremental edit apply failed: ${applyResult.error ?? 'unknown error'}`,
609
+ );
610
+ }
611
+ }
612
+ }
613
+ setChatStatus('streaming');
614
+ updateStreaming(accumulated);
615
+ },
616
+ onComplete: (fullText) => {
617
+ clearPendingAttachmentsOnce();
618
+ const normalizedText = continuationBase
619
+ ? stripContinuationOverlap(continuationBase, fullText)
620
+ : fullText;
621
+ const messageId = finalizeAssistant(normalizedText || fullText);
622
+
623
+ if (!responseEditState.applyFailed) {
624
+ const parsed = extractScriptEditOps(fullText, editParseOptions);
625
+ if (parsed.parseErrors.length > 0) {
626
+ if (responseEditState.intent === 'repair') {
627
+ rollbackAssistantTurnIfNeeded();
628
+ responseEditState.applyFailed = true;
629
+ responseEditState.applyFailureDiagnostic = parsed.parseDiagnostics[0] ?? createPatchDiagnostic(
630
+ 'patch_semantic_error',
631
+ parsed.parseErrors[0],
632
+ 'error',
633
+ {
634
+ failureKind: 'parse_error',
635
+ fixHint: 'Return exactly one valid `ifc-script-edits` block for the current script revision and do not mix it with a `js` fence.',
636
+ },
637
+ );
638
+ }
639
+ responseEditState.applyFailureStatus = 'parse_error';
640
+ responseEditState.applyFailureError = parsed.parseErrors[0];
641
+ setChatError(parsed.parseErrors[0]);
642
+ }
643
+ const canApplyCompletedOps = !(responseEditState.intent === 'repair' && parsed.parseErrors.length > 0);
644
+ const freshOps = canApplyCompletedOps
645
+ ? filterUnappliedScriptOps(parsed.operations, responseEditState.appliedOpIds)
646
+ : [];
647
+ if (freshOps.length > 0) {
648
+ const applyResult = useViewerStore.getState().applyScriptEditOps(freshOps, {
649
+ acceptedBaseRevision: responseBaseRevision,
650
+ baseContentSnapshot: responseBaseContent,
651
+ priorAcceptedOps: responseEditState.acceptedOps,
652
+ intent: responseEditState.intent,
653
+ });
654
+ if (applyResult.ok) {
655
+ applyResult.appliedOpIds.forEach((id) => responseEditState.appliedOpIds.add(id));
656
+ responseEditState.acceptedOps.push(...freshOps);
657
+ responseEditState.appliedAny = true;
658
+ useViewerStore.getState().setScriptPanelVisible(true);
659
+ } else {
660
+ rollbackAssistantTurnIfNeeded();
661
+ responseEditState.applyFailed = true;
662
+ responseEditState.applyFailureStatus = applyResult.status === 'ok' ? 'semantic_error' : (applyResult.status ?? 'semantic_error');
663
+ responseEditState.applyFailureError = applyResult.error ?? 'unknown error';
664
+ responseEditState.applyFailureDiagnostic = applyResult.diagnostic ?? null;
665
+ setChatError(
666
+ applyResult.status === 'revision_conflict'
667
+ ? `Incremental edit apply hit a revision conflict: ${applyResult.error ?? 'unknown error'}`
668
+ : `Incremental edit apply failed: ${applyResult.error ?? 'unknown error'}`,
669
+ );
670
+ }
671
+ }
672
+ }
673
+
674
+ if (!responseEditState.appliedAny && !responseEditState.applyFailed && canUsePlainCodeBlockFallback(responseEditState.intent)) {
675
+ const blocks = extractCodeBlocks(fullText);
676
+ if (blocks.length > 0) {
677
+ const lastBlock = blocks[blocks.length - 1];
678
+ const fallbackResult = useViewerStore.getState().replaceScriptContentFallback(lastBlock.code, {
679
+ intent: responseEditState.intent,
680
+ source: 'code_block_fallback',
681
+ });
682
+ if (fallbackResult.ok) {
683
+ useViewerStore.getState().setScriptPanelVisible(true);
684
+ responseEditState.fallbackApplied = true;
685
+ } else {
686
+ responseEditState.applyFailed = true;
687
+ responseEditState.applyFailureStatus = fallbackResult.status === 'ok' ? 'semantic_error' : (fallbackResult.status ?? 'semantic_error');
688
+ responseEditState.applyFailureError = fallbackResult.error ?? 'unknown error';
689
+ responseEditState.applyFailureDiagnostic = fallbackResult.diagnostic ?? null;
690
+ setChatError(`Full-script apply blocked: ${fallbackResult.error ?? 'unknown error'}`);
691
+ }
692
+ }
693
+ }
694
+
695
+ // Auto-execute if enabled
696
+ const autoExec = useViewerStore.getState().chatAutoExecute;
697
+ if (autoExec) {
698
+ if (responseEditState.appliedAny || responseEditState.fallbackApplied) {
699
+ const currentCode = useViewerStore.getState().scriptEditorContent;
700
+ if (currentCode.trim()) {
701
+ void (async () => {
702
+ const result = await execute(currentCode);
703
+ if (!result) {
704
+ const { scriptLastError, scriptLastDiagnostics, chatStatus } = useViewerStore.getState();
705
+ if (
706
+ scriptLastError &&
707
+ scriptLastError.startsWith('Preflight validation failed:') &&
708
+ chatStatus !== 'sending' &&
709
+ chatStatus !== 'streaming'
710
+ ) {
711
+ triggerAutoRepair({
712
+ error: scriptLastError,
713
+ diagnostics: scriptLastDiagnostics,
714
+ reason: 'preflight',
715
+ });
716
+ }
717
+ }
718
+ })();
719
+ }
720
+ } else if (!responseEditState.applyFailed && responseEditState.intent !== 'repair') {
721
+ const blocks = extractCodeBlocks(fullText);
722
+ if (blocks.length > 0) {
723
+ const lastBlock = blocks[blocks.length - 1];
724
+ useViewerStore.getState().setCodeExecResult(
725
+ messageId,
726
+ lastBlock.index,
727
+ { status: 'running' },
728
+ );
729
+ }
730
+ }
731
+ }
732
+
733
+ if (responseEditState.applyFailureStatus === 'revision_conflict') {
734
+ const {
735
+ chatStatus,
736
+ } = useViewerStore.getState();
737
+ if (chatStatus !== 'sending' && chatStatus !== 'streaming') {
738
+ triggerAutoRepair({
739
+ error: responseEditState.applyFailureError ?? 'Patch revision conflict.',
740
+ diagnostics: responseEditState.applyFailureDiagnostic ? [responseEditState.applyFailureDiagnostic] : [],
741
+ reason: 'patch-conflict',
742
+ });
743
+ }
744
+ } else if (responseEditState.intent === 'repair' && responseEditState.applyFailed) {
745
+ const {
746
+ chatStatus,
747
+ } = useViewerStore.getState();
748
+ if (chatStatus !== 'sending' && chatStatus !== 'streaming') {
749
+ triggerAutoRepair({
750
+ error: responseEditState.applyFailureError ?? 'Patch apply failed.',
751
+ diagnostics: responseEditState.applyFailureDiagnostic ? [responseEditState.applyFailureDiagnostic] : [],
752
+ reason: 'patch-apply',
753
+ });
754
+ }
755
+ }
756
+
757
+ commitAssistantTurn();
758
+ },
759
+ onUsageInfo: (info: UsageInfo) => {
760
+ setChatUsage(info);
761
+ },
762
+ onFinishReason: (reason) => {
763
+ setLastFinishReason(reason);
764
+ if (reason === 'length') {
765
+ setChatError('Response reached output limit. Click Continue to resume.');
766
+ }
767
+ },
768
+ onError: (err) => {
769
+ setChatError(err.message);
770
+ setChatAbortController(null);
771
+ commitAssistantTurn();
772
+ },
773
+ });
774
+ if (abortController.signal.aborted) {
775
+ commitAssistantTurn();
776
+ const currentState = useViewerStore.getState();
777
+ if (currentState.chatAbortController === abortController) {
778
+ setChatStatus('idle');
779
+ setChatAbortController(null);
780
+ }
781
+ }
782
+ }, [
783
+ status, activeModel, attachments, authToken,
784
+ addMessage, setChatStatus, updateStreaming, finalizeAssistant,
785
+ setChatError, setChatAbortController, clearAttachments, setChatUsage, resizeInput,
786
+ buildRepairPromptFromLiveState, triggerAutoRepair, execute,
787
+ ]);
788
+
789
+ const handleSend = useCallback(() => {
790
+ doSend(inputText);
791
+ }, [inputText, doSend]);
792
+
793
+ // Allow other panels (e.g. ScriptPanel errors) to trigger a chat repair turn.
794
+ useEffect(() => {
795
+ if (!pendingPrompt) return;
796
+ if (status === 'sending' || status === 'streaming') return;
797
+ consumePendingPrompt();
798
+ const intent: ScriptMutationIntent | undefined = (
799
+ pendingPrompt.startsWith('The script needs a root-cause repair.')
800
+ || pendingPrompt.startsWith('The script needs a targeted fix.')
801
+ )
802
+ ? 'repair'
803
+ : undefined;
804
+ void doSend(pendingPrompt, { intent });
805
+ }, [pendingPrompt, status, consumePendingPrompt, doSend]);
806
+
807
+ useEffect(() => {
808
+ if (!pendingRepairRequest) return;
809
+ if (status === 'sending' || status === 'streaming') return;
810
+ consumePendingRepairRequest();
811
+ void doSend(buildRepairPromptFromLiveState(pendingRepairRequest), {
812
+ intent: 'repair',
813
+ repairDiagnostics: pendingRepairRequest.diagnostics,
814
+ requestedRepairScope: pendingRepairRequest.requestedRepairScope,
815
+ rootCauseKey: pendingRepairRequest.rootCauseKey,
816
+ });
817
+ }, [pendingRepairRequest, status, consumePendingRepairRequest, buildRepairPromptFromLiveState, doSend]);
818
+
819
+ const handleContinue = useCallback(() => {
820
+ const state = useViewerStore.getState();
821
+ const partial = state.chatStreamingContent.trim();
822
+ const lastAssistant = [...state.chatMessages].reverse().find((m) => m.role === 'assistant');
823
+ const continuationBase = partial || lastAssistant?.content || '';
824
+ if (!continuationBase) return;
825
+
826
+ // Preserve the partial completion in history, then request continuation.
827
+ if (partial) {
828
+ finalizeAssistant(partial);
829
+ }
830
+ setChatError(null);
831
+ doSend(CONTINUE_PROMPT, { continuationBase });
832
+ }, [doSend, finalizeAssistant, setChatError]);
833
+
834
+ const handleStop = useCallback(() => {
835
+ const controller = useViewerStore.getState().chatAbortController;
836
+ if (controller) {
837
+ controller.abort();
838
+ const partial = useViewerStore.getState().chatStreamingContent;
839
+ if (partial) {
840
+ finalizeAssistant(partial);
841
+ } else {
842
+ setChatStatus('idle');
843
+ setChatAbortController(null);
844
+ }
845
+ }
846
+ }, [finalizeAssistant, setChatStatus, setChatAbortController]);
847
+
848
+ const handleKeyDown = useCallback((e: KeyboardEvent<HTMLTextAreaElement>) => {
849
+ if (e.key === 'Enter' && !e.shiftKey) {
850
+ e.preventDefault();
851
+ handleSend();
852
+ }
853
+ }, [handleSend]);
854
+
855
+ // ── Error feedback (Fix this) ──
856
+ const handleFixError = useCallback((code: string, errorMsg: string) => {
857
+ const diagnostics = useViewerStore.getState().scriptLastDiagnostics;
858
+ const liveCode = useViewerStore.getState().scriptEditorContent;
859
+ const staleCode = code.trim() !== liveCode.trim() ? code : undefined;
860
+ void doSend(buildRepairPromptFromLiveState({
861
+ error: errorMsg,
862
+ diagnostics,
863
+ staleCodeBlock: staleCode,
864
+ reason: 'runtime',
865
+ }), {
866
+ intent: 'repair',
867
+ repairDiagnostics: diagnostics,
868
+ requestedRepairScope: getPrimaryRootCause(diagnostics)?.repairScope,
869
+ rootCauseKey: getPrimaryRootCause(diagnostics)?.rootCauseKey,
870
+ });
871
+ }, [buildRepairPromptFromLiveState, doSend]);
872
+
873
+ // ── Clickable example prompts ──
874
+ const handleExampleClick = useCallback((prompt: string) => {
875
+ setInputText(prompt);
876
+ inputRef.current?.focus();
877
+ }, []);
878
+
879
+ // ── Clear with confirmation ──
880
+ const handleClearClick = useCallback(() => {
881
+ if (messages.length <= 2) {
882
+ resetScriptEditorForNewChat();
883
+ clearMessages();
884
+ setInputText('');
885
+ setLastFinishReason(null);
886
+ } else {
887
+ setShowClearConfirm(true);
888
+ }
889
+ }, [messages.length, clearMessages, resetScriptEditorForNewChat]);
890
+
891
+ const confirmClear = useCallback(() => {
892
+ resetScriptEditorForNewChat();
893
+ clearMessages();
894
+ setInputText('');
895
+ setLastFinishReason(null);
896
+ setShowClearConfirm(false);
897
+ }, [clearMessages, resetScriptEditorForNewChat]);
898
+
899
+ // ── File upload (button + drag-drop + paste) ──
900
+ const processFiles = useCallback(async (files: FileList | File[]) => {
901
+ const model = getModelById(activeModel);
902
+ const supportsImages = model?.supportsImages ?? false;
903
+ const supportsFileAttachments = model?.supportsFileAttachments ?? true;
904
+ let remainingSlots = Math.max(0, MAX_ATTACHMENTS_PER_MESSAGE - attachments.length);
905
+
906
+ for (const file of Array.from(files)) {
907
+ if (remainingSlots <= 0) {
908
+ setChatError(`You can attach up to ${MAX_ATTACHMENTS_PER_MESSAGE} files per message.`);
909
+ break;
910
+ }
911
+ try {
912
+ // Handle image files
913
+ if (file.type.startsWith('image/')) {
914
+ if (!supportsImages) {
915
+ setChatError('Selected model does not support image input. Switch model to attach images.');
916
+ continue;
917
+ }
918
+ if (file.size > MAX_IMAGE_ATTACHMENT_BYTES) {
919
+ setChatError(`Image attachments must be smaller than ${Math.round(MAX_IMAGE_ATTACHMENT_BYTES / 1_000_000)} MB.`);
920
+ continue;
921
+ }
922
+ const base64 = await imageFileToCompressedBase64(file);
923
+ if (base64.length > MAX_INLINE_IMAGE_DATA_URL_CHARS) {
924
+ setChatError('Image attachment is still too large after compression. Please use a smaller image.');
925
+ continue;
926
+ }
927
+ const attachment: FileAttachment = {
928
+ id: createAttachmentId(),
929
+ name: file.name,
930
+ type: 'image/jpeg',
931
+ size: Math.round((base64.length * 3) / 4),
932
+ imageBase64: base64,
933
+ isImage: true,
934
+ };
935
+ addAttachment(attachment);
936
+ remainingSlots -= 1;
937
+ continue;
938
+ }
939
+ // Only accept text-based files
940
+ if (!file.name.match(/\.(csv|json|txt|tsv)$/i)) continue;
941
+ if (!supportsFileAttachments) {
942
+ setChatError('Selected model does not support file attachments. Switch model to attach files.');
943
+ continue;
944
+ }
945
+ if (file.size > MAX_TEXT_ATTACHMENT_BYTES) {
946
+ setChatError(`Text attachments must be smaller than ${Math.round(MAX_TEXT_ATTACHMENT_BYTES / 1024)} KB.`);
947
+ continue;
948
+ }
949
+ const text = await file.text();
950
+ const attachment: FileAttachment = {
951
+ id: createAttachmentId(),
952
+ name: file.name,
953
+ type: file.type || 'text/plain',
954
+ size: file.size,
955
+ textContent: text,
956
+ };
957
+ if (file.name.endsWith('.csv') || file.name.endsWith('.tsv')) {
958
+ const { columns, rows } = parseCSV(text);
959
+ attachment.csvColumns = columns;
960
+ attachment.csvData = rows;
961
+ }
962
+ addAttachment(attachment);
963
+ remainingSlots -= 1;
964
+ } catch (error) {
965
+ setChatError(`Could not read ${file.name}: ${error instanceof Error ? error.message : String(error)}`);
966
+ }
967
+ }
968
+ }, [activeModel, addAttachment, attachments.length, setChatError]);
969
+
970
+ const handleFileUpload = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
971
+ const files = e.target.files;
972
+ if (!files) return;
973
+ await processFiles(files);
974
+ e.target.value = '';
975
+ }, [processFiles]);
976
+
977
+ // Drag and drop handlers
978
+ const handleDragEnter = useCallback((e: DragEvent) => {
979
+ e.preventDefault();
980
+ e.stopPropagation();
981
+ dragCounterRef.current++;
982
+ if (e.dataTransfer.types.includes('Files')) {
983
+ setIsDragging(true);
984
+ }
985
+ }, []);
986
+
987
+ const handleDragLeave = useCallback((e: DragEvent) => {
988
+ e.preventDefault();
989
+ e.stopPropagation();
990
+ dragCounterRef.current--;
991
+ if (dragCounterRef.current === 0) {
992
+ setIsDragging(false);
993
+ }
994
+ }, []);
995
+
996
+ const handleDragOver = useCallback((e: DragEvent) => {
997
+ e.preventDefault();
998
+ e.stopPropagation();
999
+ }, []);
1000
+
1001
+ const handleDrop = useCallback(async (e: DragEvent) => {
1002
+ e.preventDefault();
1003
+ e.stopPropagation();
1004
+ dragCounterRef.current = 0;
1005
+ setIsDragging(false);
1006
+
1007
+ const files = e.dataTransfer.files;
1008
+ if (files.length > 0) {
1009
+ await processFiles(files);
1010
+ }
1011
+ }, [processFiles]);
1012
+
1013
+ // ── Paste handler for images ──
1014
+ const handlePaste = useCallback(async (e: React.ClipboardEvent) => {
1015
+ const items = e.clipboardData?.items;
1016
+ if (!items) return;
1017
+
1018
+ const imageFiles: File[] = [];
1019
+ for (const item of Array.from(items)) {
1020
+ if (item.type.startsWith('image/')) {
1021
+ const file = item.getAsFile();
1022
+ if (file) imageFiles.push(file);
1023
+ }
1024
+ }
1025
+
1026
+ if (imageFiles.length > 0) {
1027
+ e.preventDefault();
1028
+ await processFiles(imageFiles);
1029
+ }
1030
+ }, [processFiles]);
1031
+
1032
+ const isActive = status === 'streaming' || status === 'sending';
1033
+ const modelForUi = getModelById(activeModel);
1034
+ const modelSupportsImages = modelForUi?.supportsImages ?? false;
1035
+ const modelSupportsFiles = modelForUi?.supportsFileAttachments ?? true;
1036
+ const attachmentAccept = [
1037
+ modelSupportsFiles ? '.csv,.json,.txt,.tsv' : '',
1038
+ modelSupportsImages ? 'image/*' : '',
1039
+ ].filter(Boolean).join(',');
1040
+ const canAttachInput = modelSupportsFiles || modelSupportsImages;
1041
+ const clerkEnabled = isClerkConfigured();
1042
+ const showUpgradeNudge = Boolean(error && (error.includes('Upgrade to Pro') || error.includes('daily limit')));
1043
+ const showSupportEmail = Boolean(error && error.includes('louis@ltplus.com'));
1044
+ const canContinue = Boolean(
1045
+ !isActive && (streamingContent.trim().length > 0 || lastFinishReason === 'length'),
1046
+ );
1047
+ const openUpgradePage = useCallback(() => {
1048
+ const currentPath = `${window.location.pathname}${window.location.search}`;
1049
+ const target = `/upgrade?returnTo=${encodeURIComponent(currentPath)}`;
1050
+ window.history.pushState({}, '', target);
1051
+ window.dispatchEvent(new PopStateEvent('popstate'));
1052
+ }, []);
1053
+
1054
+ return (
1055
+ <div
1056
+ className="h-full flex flex-col bg-background relative"
1057
+ onDragEnter={handleDragEnter}
1058
+ onDragLeave={handleDragLeave}
1059
+ onDragOver={handleDragOver}
1060
+ onDrop={handleDrop}
1061
+ >
1062
+ {/* Drag overlay */}
1063
+ {isDragging && (
1064
+ <div className="absolute inset-0 z-50 bg-blue-500/10 border-2 border-dashed border-blue-500 rounded-md flex items-center justify-center pointer-events-none">
1065
+ <div className="flex flex-col items-center gap-2 text-blue-500">
1066
+ <Paperclip className="h-8 w-8" />
1067
+ <span className="text-sm font-medium">Drop files or images</span>
1068
+ </div>
1069
+ </div>
1070
+ )}
1071
+
1072
+ {/* Header */}
1073
+ <div className="flex items-center gap-0.5 px-2 py-1 border-b shrink-0">
1074
+ <Tooltip>
1075
+ <TooltipTrigger asChild>
1076
+ <Button
1077
+ variant="ghost"
1078
+ size="icon-xs"
1079
+ onClick={handleClearClick}
1080
+ disabled={messages.length === 0}
1081
+ >
1082
+ <Trash2 className="h-3.5 w-3.5" />
1083
+ </Button>
1084
+ </TooltipTrigger>
1085
+ <TooltipContent>Clear</TooltipContent>
1086
+ </Tooltip>
1087
+
1088
+ <ModelSelector hasPro={hasPro} />
1089
+ <div className="flex-1" />
1090
+
1091
+ <Tooltip>
1092
+ <TooltipTrigger asChild>
1093
+ <Button
1094
+ variant="ghost"
1095
+ size="icon-xs"
1096
+ onClick={() => setAutoExecute(!autoExecute)}
1097
+ className={autoExecute ? 'text-amber-500' : ''}
1098
+ >
1099
+ <Zap className="h-3.5 w-3.5" />
1100
+ </Button>
1101
+ </TooltipTrigger>
1102
+ <TooltipContent>Auto-run: {autoExecute ? 'ON' : 'OFF'}</TooltipContent>
1103
+ </Tooltip>
1104
+
1105
+ {clerkEnabled && (
1106
+ <>
1107
+ <SignedOut>
1108
+ <SignInButton mode="modal">
1109
+ <Button variant="ghost" size="sm" className="h-6 px-2 text-xs text-muted-foreground">
1110
+ Sign in
1111
+ </Button>
1112
+ </SignInButton>
1113
+ </SignedOut>
1114
+ {!hasPro && (
1115
+ <Button
1116
+ variant="ghost"
1117
+ size="sm"
1118
+ className="h-6 px-2 text-xs text-muted-foreground"
1119
+ onClick={openUpgradePage}
1120
+ >
1121
+ Pro
1122
+ </Button>
1123
+ )}
1124
+ <SignedIn>
1125
+ <UserButton afterSignOutUrl="/" />
1126
+ </SignedIn>
1127
+ </>
1128
+ )}
1129
+
1130
+ {onClose && (
1131
+ <Button variant="ghost" size="icon-xs" onClick={onClose}>
1132
+ <X className="h-3.5 w-3.5" />
1133
+ </Button>
1134
+ )}
1135
+ </div>
1136
+
1137
+ {/* Clear confirmation */}
1138
+ {showClearConfirm && (
1139
+ <div className="px-3 py-2 bg-destructive/5 border-b flex items-center gap-2 text-xs">
1140
+ <span className="text-muted-foreground">Clear {messages.length} messages?</span>
1141
+ <Button
1142
+ variant="destructive"
1143
+ size="sm"
1144
+ onClick={confirmClear}
1145
+ className="h-5 px-2 text-xs"
1146
+ >
1147
+ Clear
1148
+ </Button>
1149
+ <Button
1150
+ variant="ghost"
1151
+ size="sm"
1152
+ onClick={() => setShowClearConfirm(false)}
1153
+ className="h-5 px-2 text-xs"
1154
+ >
1155
+ Cancel
1156
+ </Button>
1157
+ </div>
1158
+ )}
1159
+
1160
+ {/* Messages */}
1161
+ <div className="flex-1 min-h-0 overflow-y-auto relative" ref={scrollRef}>
1162
+ {/* Empty state */}
1163
+ {messages.length === 0 && !streamingContent && (
1164
+ <div className="flex flex-col justify-end h-full px-3 pb-2">
1165
+ <p className="text-xs text-muted-foreground/60 mb-2">Try something:</p>
1166
+ <div className="flex flex-col gap-1">
1167
+ {EXAMPLE_PROMPTS.map((prompt) => (
1168
+ <button
1169
+ key={prompt}
1170
+ onClick={() => handleExampleClick(prompt)}
1171
+ className="text-xs text-left px-2.5 py-1.5 rounded border border-transparent hover:border-border hover:bg-muted/50 text-muted-foreground hover:text-foreground transition-colors"
1172
+ >
1173
+ {prompt}
1174
+ </button>
1175
+ ))}
1176
+ </div>
1177
+ </div>
1178
+ )}
1179
+
1180
+ {/* Message list */}
1181
+ {messages.map((msg) => (
1182
+ <ChatMessageComponent
1183
+ key={msg.id}
1184
+ message={msg}
1185
+ onFixError={handleFixError}
1186
+ />
1187
+ ))}
1188
+
1189
+ {/* Streaming assistant response */}
1190
+ {streamingContent && (
1191
+ <ChatMessageComponent
1192
+ message={{
1193
+ id: 'streaming',
1194
+ role: 'assistant',
1195
+ content: streamingContent,
1196
+ createdAt: Date.now(),
1197
+ codeBlocks: extractCodeBlocks(streamingContent),
1198
+ }}
1199
+ isStreaming
1200
+ />
1201
+ )}
1202
+
1203
+ {/* Sending indicator */}
1204
+ {status === 'sending' && (
1205
+ <div className="flex items-center gap-2 px-3 py-2 text-muted-foreground">
1206
+ <Loader2 className="h-3.5 w-3.5 animate-spin" />
1207
+ <span className="text-xs">Thinking...</span>
1208
+ </div>
1209
+ )}
1210
+ </div>
1211
+
1212
+ {/* Scroll to bottom button */}
1213
+ {showScrollBtn && (
1214
+ <div className="absolute bottom-[120px] right-4 z-20">
1215
+ <Button
1216
+ variant="outline"
1217
+ size="icon-xs"
1218
+ onClick={scrollToBottom}
1219
+ className="rounded-full shadow-md bg-background"
1220
+ >
1221
+ <ArrowDown className="h-3.5 w-3.5" />
1222
+ </Button>
1223
+ </div>
1224
+ )}
1225
+
1226
+ {/* Error display */}
1227
+ {error && (
1228
+ <div className="px-3 py-1.5 bg-destructive/10 text-destructive text-xs border-t flex items-center justify-between gap-2">
1229
+ <span>{error}</span>
1230
+ <div className="flex items-center gap-2">
1231
+ {canContinue && (
1232
+ <Button
1233
+ variant="outline"
1234
+ size="sm"
1235
+ className="h-5 px-2 text-[10px]"
1236
+ onClick={handleContinue}
1237
+ >
1238
+ Continue
1239
+ </Button>
1240
+ )}
1241
+ {showUpgradeNudge && clerkEnabled && (
1242
+ <Button
1243
+ variant="outline"
1244
+ size="sm"
1245
+ className="h-5 px-2 text-[10px]"
1246
+ onClick={openUpgradePage}
1247
+ >
1248
+ Upgrade
1249
+ </Button>
1250
+ )}
1251
+ {showSupportEmail && (
1252
+ <a className="underline text-[10px]" href="mailto:louis@ltplus.com">
1253
+ Contact support
1254
+ </a>
1255
+ )}
1256
+ </div>
1257
+ </div>
1258
+ )}
1259
+
1260
+ {/* Attachments preview */}
1261
+ {attachments.length > 0 && (
1262
+ <div className="px-2 py-1 border-t flex flex-wrap gap-1">
1263
+ {attachments.map((a) => (
1264
+ <span
1265
+ key={a.id}
1266
+ className="inline-flex items-center gap-1 px-2 py-0.5 rounded bg-muted text-xs"
1267
+ >
1268
+ {a.isImage ? (
1269
+ <>
1270
+ {a.imageBase64 && (
1271
+ <img
1272
+ src={a.imageBase64}
1273
+ alt={a.name}
1274
+ className="h-6 w-6 object-cover rounded"
1275
+ />
1276
+ )}
1277
+ <ImageIcon className="h-3 w-3" />
1278
+ </>
1279
+ ) : (
1280
+ <Paperclip className="h-3 w-3" />
1281
+ )}
1282
+ {a.name}
1283
+ <button
1284
+ className="ml-0.5 hover:text-destructive"
1285
+ onClick={() => removeAttachment(a.id)}
1286
+ >
1287
+ <X className="h-3 w-3" />
1288
+ </button>
1289
+ </span>
1290
+ ))}
1291
+ </div>
1292
+ )}
1293
+
1294
+ {/* Input area */}
1295
+ <div className="shrink-0 border-t p-2">
1296
+ <div className="flex items-end gap-1.5">
1297
+ <input
1298
+ ref={fileInputRef}
1299
+ type="file"
1300
+ accept={attachmentAccept}
1301
+ multiple
1302
+ onChange={handleFileUpload}
1303
+ className="hidden"
1304
+ />
1305
+ <Tooltip>
1306
+ <TooltipTrigger asChild>
1307
+ <Button
1308
+ variant="ghost"
1309
+ size="icon-xs"
1310
+ onClick={() => fileInputRef.current?.click()}
1311
+ disabled={!canAttachInput}
1312
+ className="shrink-0 mb-0.5"
1313
+ >
1314
+ <Paperclip className="h-3.5 w-3.5" />
1315
+ </Button>
1316
+ </TooltipTrigger>
1317
+ <TooltipContent>
1318
+ {canAttachInput
1319
+ ? 'Attach file or image (paste, drag & drop)'
1320
+ : 'Selected model does not support attachments'}
1321
+ </TooltipContent>
1322
+ </Tooltip>
1323
+
1324
+ <textarea
1325
+ ref={inputRef}
1326
+ value={inputText}
1327
+ onChange={(e) => {
1328
+ setInputText(e.target.value);
1329
+ resizeInput();
1330
+ }}
1331
+ onKeyDown={handleKeyDown}
1332
+ onPaste={handlePaste}
1333
+ placeholder="Ask anything..."
1334
+ rows={1}
1335
+ className="flex-1 resize-none rounded-md border border-input bg-background text-foreground placeholder:text-muted-foreground px-3 py-1.5 text-sm min-h-[32px] max-h-[120px] focus:outline-none focus:ring-1 focus:ring-ring"
1336
+ style={{ height: 'auto', overflow: 'hidden' }}
1337
+ />
1338
+
1339
+ {isActive ? (
1340
+ <Tooltip>
1341
+ <TooltipTrigger asChild>
1342
+ <Button
1343
+ variant="ghost"
1344
+ size="icon-xs"
1345
+ onClick={handleStop}
1346
+ className="shrink-0 mb-0.5"
1347
+ >
1348
+ <Square className="h-3.5 w-3.5" />
1349
+ </Button>
1350
+ </TooltipTrigger>
1351
+ <TooltipContent>Stop generating</TooltipContent>
1352
+ </Tooltip>
1353
+ ) : (
1354
+ <Tooltip>
1355
+ <TooltipTrigger asChild>
1356
+ <Button
1357
+ variant="default"
1358
+ size="icon-xs"
1359
+ onClick={handleSend}
1360
+ disabled={!inputText.trim()}
1361
+ className="shrink-0 mb-0.5"
1362
+ >
1363
+ <Send className="h-3.5 w-3.5" />
1364
+ </Button>
1365
+ </TooltipTrigger>
1366
+ <TooltipContent>Send (Enter)</TooltipContent>
1367
+ </Tooltip>
1368
+ )}
1369
+ </div>
1370
+ <div className="flex items-center justify-between mt-1 px-0.5">
1371
+ {isActive ? (
1372
+ <span className="text-[10px] text-muted-foreground/50">Streaming...</span>
1373
+ ) : displayUsage ? (
1374
+ <Tooltip>
1375
+ <TooltipTrigger asChild>
1376
+ <div className="flex items-center gap-1.5 cursor-default">
1377
+ <div className="w-12 h-1 bg-muted rounded-full overflow-hidden">
1378
+ <div
1379
+ className={`h-full rounded-full transition-all ${displayUsage.pct >= 90 ? 'bg-destructive' : displayUsage.pct >= 70 ? 'bg-amber-500' : 'bg-emerald-500'
1380
+ }`}
1381
+ style={{ width: `${Math.min(100, displayUsage.pct)}%` }}
1382
+ />
1383
+ </div>
1384
+ <span className="text-[10px] text-muted-foreground/40 tabular-nums">{displayUsage.pct}%</span>
1385
+ </div>
1386
+ </TooltipTrigger>
1387
+ <TooltipContent>
1388
+ {displayUsage.type === 'credits'
1389
+ ? `${displayUsage.used}/${displayUsage.limit} credits · resets ${usageResetLabel}`
1390
+ : `${displayUsage.used}/${displayUsage.limit} requests · resets ${usageResetLabel}`
1391
+ }
1392
+ </TooltipContent>
1393
+ </Tooltip>
1394
+ ) : (
1395
+ <span className="text-[10px] text-muted-foreground/40">Shift+Enter new line</span>
1396
+ )}
1397
+ <span className="text-[10px] text-muted-foreground/30">⌘L</span>
1398
+ </div>
1399
+ </div>
1400
+ </div>
1401
+ );
1402
+ }