@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,468 @@
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
+ * Chat state slice — manages LLM chat messages, streaming state,
7
+ * model selection, and code execution results.
8
+ */
9
+
10
+ import type { StateCreator } from 'zustand';
11
+ import type { ChatMessage, ChatRepairRequest, ChatStatus, CodeExecResult, FileAttachment } from '../../lib/llm/types.js';
12
+ import { coerceModelForEntitlement, DEFAULT_FREE_MODEL } from '../../lib/llm/models.js';
13
+ import { extractCodeBlocks } from '../../lib/llm/code-extractor.js';
14
+ import type { ScriptDiagnostic } from '../../lib/llm/script-diagnostics.js';
15
+ import { formatDiagnosticsForPrompt, getPrimaryRootCause, groupDiagnosticsByRootCause } from '../../lib/llm/script-diagnostics.js';
16
+
17
+ const MODEL_STORAGE_KEY = 'ifc-lite-chat-model';
18
+ const MESSAGES_STORAGE_KEY = 'ifc-lite-chat-messages';
19
+ const AUTO_EXEC_STORAGE_KEY = 'ifc-lite-chat-auto-execute';
20
+ const PANEL_VISIBLE_STORAGE_KEY = 'ifc-lite-chat-panel-visible';
21
+ const MAX_MESSAGES = 200;
22
+
23
+ function getModelStorageKey(userId: string | null): string {
24
+ return userId ? `${MODEL_STORAGE_KEY}:${userId}` : MODEL_STORAGE_KEY;
25
+ }
26
+
27
+ function getMessagesStorageKey(userId: string | null): string {
28
+ return userId ? `${MESSAGES_STORAGE_KEY}:${userId}` : `${MESSAGES_STORAGE_KEY}:anonymous`;
29
+ }
30
+
31
+ export interface ChatSlice {
32
+ // State
33
+ chatPanelVisible: boolean;
34
+ chatMessages: ChatMessage[];
35
+ chatStatus: ChatStatus;
36
+ chatStreamingContent: string;
37
+ chatActiveModel: string;
38
+ chatAutoExecute: boolean;
39
+ chatError: string | null;
40
+ chatAbortController: AbortController | null;
41
+ chatAttachments: FileAttachment[];
42
+ chatPendingPrompt: string | null;
43
+ chatPendingRepairRequest: ChatRepairRequest | null;
44
+ /** Auto-captured viewport screenshot (base64 data URL) to include with next LLM message */
45
+ chatViewportScreenshot: string | null;
46
+ /** Clerk JWT for authenticated API calls (null for anonymous/free tier) */
47
+ chatAuthToken: string | null;
48
+ /** Whether the current user has a pro subscription */
49
+ chatHasPro: boolean;
50
+ /** Usage info from the server: credits (pro) or request count (free) */
51
+ chatUsage: ChatUsage | null;
52
+ /** User ID used to scope persisted model preference (null for anonymous). */
53
+ chatStorageUserId: string | null;
54
+
55
+ // Actions
56
+ setChatPanelVisible: (visible: boolean) => void;
57
+ toggleChatPanel: () => void;
58
+ addChatMessage: (message: ChatMessage) => void;
59
+ updateLastAssistantMessage: (content: string) => void;
60
+ /** Finalize streaming into a real message. Returns the finalized message ID. */
61
+ finalizeAssistantMessage: (content: string) => string;
62
+ setChatStatus: (status: ChatStatus) => void;
63
+ setChatStreamingContent: (content: string) => void;
64
+ setChatActiveModel: (model: string) => void;
65
+ setChatAutoExecute: (auto: boolean) => void;
66
+ setChatError: (error: string | null) => void;
67
+ setChatAbortController: (controller: AbortController | null) => void;
68
+ setCodeExecResult: (messageId: string, blockIndex: number, result: CodeExecResult) => void;
69
+ addChatAttachment: (attachment: FileAttachment) => void;
70
+ removeChatAttachment: (attachmentId: string) => void;
71
+ clearChatAttachments: () => void;
72
+ clearChatMessages: () => void;
73
+ queueChatPrompt: (prompt: string) => void;
74
+ consumeChatPendingPrompt: () => void;
75
+ queueChatRepairRequest: (request: ChatRepairRequest) => void;
76
+ consumeChatPendingRepairRequest: () => void;
77
+ /** Send an error from a failed code block back to the chat as a user message for retry. */
78
+ sendErrorFeedback: (code: string, error: string) => void;
79
+ /** Store a viewport screenshot to include with the next LLM message */
80
+ setChatViewportScreenshot: (dataUrl: string | null) => void;
81
+ /** Set the Clerk auth token (called by ClerkProvider wrapper when user signs in) */
82
+ setChatAuthToken: (token: string | null) => void;
83
+ /** Set whether user has pro subscription (called by ClerkProvider wrapper) */
84
+ setChatHasPro: (hasPro: boolean) => void;
85
+ /** Update usage info from server response headers */
86
+ setChatUsage: (usage: ChatUsage | null) => void;
87
+ /** Switch the active chat user/session context. */
88
+ switchChatUserContext: (
89
+ userId: string | null,
90
+ hasPro: boolean,
91
+ options?: { clearPersistedCurrent?: boolean; restoreMessages?: boolean },
92
+ ) => void;
93
+ }
94
+
95
+ export interface ChatUsage {
96
+ type: 'credits' | 'requests';
97
+ used: number;
98
+ limit: number;
99
+ pct: number;
100
+ resetAt: number;
101
+ billable?: boolean;
102
+ }
103
+
104
+ /** Build the standardized "Fix this" feedback message sent to the LLM. */
105
+ export function buildErrorFeedbackContent(
106
+ code: string,
107
+ error: string,
108
+ options?: {
109
+ diagnostics?: ScriptDiagnostic[];
110
+ currentRevision?: number;
111
+ currentSelection?: { from: number; to: number };
112
+ staleCodeBlock?: string;
113
+ reason?: ChatRepairRequest['reason'];
114
+ requestedRepairScope?: ChatRepairRequest['requestedRepairScope'];
115
+ },
116
+ ): string {
117
+ const reason = options?.reason ?? 'runtime';
118
+ const rootCauseGroups = groupDiagnosticsByRootCause(options?.diagnostics ?? []);
119
+ const primaryRootCause = getPrimaryRootCause(options?.diagnostics ?? []);
120
+ const requestedRepairScope = options?.requestedRepairScope ?? primaryRootCause?.repairScope;
121
+ const diagnosticsBlock = options?.diagnostics && options.diagnostics.length > 0
122
+ ? `\nStructured diagnostics:\n${formatDiagnosticsForPrompt(options.diagnostics)}\n`
123
+ : '';
124
+ const revisionLine = options?.currentRevision !== undefined
125
+ ? `Current script revision: ${options.currentRevision}\n`
126
+ : '';
127
+ const selectionLine = options?.currentSelection
128
+ ? `Current selection: from=${options.currentSelection.from}, to=${options.currentSelection.to}\n`
129
+ : '';
130
+ const staleBlock = options?.staleCodeBlock
131
+ ? `\nPrevious message code block for reference only (it may be stale relative to the editor):\n\n\`\`\`js\n${options.staleCodeBlock}\n\`\`\`\n`
132
+ : '';
133
+ const rootCauseBlock = primaryRootCause
134
+ ? `\nRoot cause to fix first:\n- key: ${primaryRootCause.rootCauseKey}\n- scope: ${requestedRepairScope ?? primaryRootCause.repairScope}\n- summary: ${primaryRootCause.summary}\n`
135
+ : '';
136
+ const evidenceBlock = rootCauseGroups.length > 0
137
+ ? `\nSupporting evidence:\n${rootCauseGroups.flatMap((group) => group.evidence.slice(0, 3).map((evidence) => {
138
+ const method = evidence.methodName ? ` method=${evidence.methodName};` : '';
139
+ const range = evidence.range ? ` range=${formatRange(evidence.range)};` : '';
140
+ const snippet = evidence.snippet ? ` snippet=${JSON.stringify(evidence.snippet.trim())};` : '';
141
+ return `- [${group.rootCauseKey}]${method}${range}${snippet}`.trimEnd();
142
+ })).join('\n')}\n`
143
+ : '';
144
+
145
+ return `The script needs a root-cause repair.\n\nFailure type: ${reason}\n${revisionLine}${selectionLine}\n\`\`\`\n${error}\n\`\`\`${rootCauseBlock}${evidenceBlock}${diagnosticsBlock}\nHere is the current script that should be repaired in place:\n\n\`\`\`js\n${code}\n\`\`\`${staleBlock}\nPlease fix the underlying cause in the existing script, not just the first visible symptom.\n- Preserve the project handle, storey handles, loop variables, and surrounding declarations unless they are the direct cause of the error.
146
+ - Match the requested repair scope above: \`local\` for one call/site, \`block\` for a related cluster, \`structural\` for broader context-preserving repairs. Use a full rewrite only if the user explicitly asked for it.
147
+ - Return exactly one \`ifc-script-edits\` block that patches the CURRENT script revision.
148
+ - Use exact SEARCH/REPLACE blocks inside that fence. Copy SEARCH text verbatim from the CURRENT script.
149
+ - Every SEARCH block must match exactly one location in the CURRENT script. If a match is missing or ambiguous, add more unchanged surrounding context.
150
+ - For insertions, include unchanged surrounding context in SEARCH and place the inserted code inside REPLACE. Do not use an empty SEARCH block.
151
+ - Do NOT return a \`js\` fence for repair turns.
152
+ - Do NOT use \`replaceAll\` unless the user explicitly asked to regenerate the full script.
153
+ - Do NOT answer with a detached fragment or smaller local body when the current script is larger and the root cause spans surrounding context.
154
+ - If the diagnostics share one root cause, you may use multiple coordinated SEARCH/REPLACE blocks in one patch to resolve that cause.
155
+ - If you are recovering from a patch conflict, re-target the latest revision shown above and copy SEARCH blocks from that latest revision, not from an older reply.
156
+ - If a previous answer was rejected for losing script context, keep the full script intact and patch only the necessary regions.
157
+
158
+ Return only the repair patch.`;
159
+ }
160
+
161
+ function loadStoredModel(userId: string | null, fallback?: string): string {
162
+ try {
163
+ const perUserKey = getModelStorageKey(userId);
164
+ const fromUserKey = localStorage.getItem(perUserKey);
165
+ if (fromUserKey) return fromUserKey;
166
+ // Backward compatibility: migrate previous global key into user-specific key on first read.
167
+ if (userId) {
168
+ const legacy = localStorage.getItem(MODEL_STORAGE_KEY);
169
+ if (legacy) {
170
+ localStorage.setItem(perUserKey, legacy);
171
+ return legacy;
172
+ }
173
+ }
174
+ return fallback ?? DEFAULT_FREE_MODEL.id;
175
+ } catch {
176
+ return fallback ?? DEFAULT_FREE_MODEL.id;
177
+ }
178
+ }
179
+
180
+ function loadStoredAutoExecute(): boolean {
181
+ try {
182
+ const val = localStorage.getItem(AUTO_EXEC_STORAGE_KEY);
183
+ return val === null ? true : val === 'true';
184
+ } catch {
185
+ return true;
186
+ }
187
+ }
188
+
189
+ function loadStoredPanelVisible(): boolean {
190
+ try {
191
+ return localStorage.getItem(PANEL_VISIBLE_STORAGE_KEY) === 'true';
192
+ } catch {
193
+ return false;
194
+ }
195
+ }
196
+
197
+ /** Load persisted messages from localStorage. */
198
+ function loadStoredMessages(userId: string | null): ChatMessage[] {
199
+ try {
200
+ const raw = localStorage.getItem(getMessagesStorageKey(userId));
201
+ if (!raw) return [];
202
+ const parsed = JSON.parse(raw) as Array<Record<string, unknown>>;
203
+ return trimChatMessages(parsed.flatMap(deserializeStoredMessage));
204
+ } catch {
205
+ return [];
206
+ }
207
+ }
208
+
209
+ /** Persist messages to localStorage. */
210
+ function persistMessages(messages: ChatMessage[], userId: string | null) {
211
+ try {
212
+ // Only keep last 50 messages in storage to avoid quota issues
213
+ const toStore = trimChatMessages(messages).slice(-50).map((m) => ({
214
+ id: m.id,
215
+ role: m.role,
216
+ content: m.content,
217
+ createdAt: m.createdAt,
218
+ codeBlocks: m.codeBlocks,
219
+ attachments: m.attachments,
220
+ // Serialize Map as array of entries
221
+ execResults: m.execResults ? Array.from(m.execResults.entries()) : undefined,
222
+ }));
223
+ localStorage.setItem(getMessagesStorageKey(userId), JSON.stringify(toStore));
224
+ } catch { /* quota exceeded — ignore */ }
225
+ }
226
+
227
+ function trimChatMessages(messages: ChatMessage[]): ChatMessage[] {
228
+ if (messages.length <= MAX_MESSAGES) return messages;
229
+ return messages.slice(-MAX_MESSAGES);
230
+ }
231
+
232
+ function deserializeStoredMessage(value: Record<string, unknown>): ChatMessage[] {
233
+ const id = typeof value.id === 'string' ? value.id : null;
234
+ const role = value.role;
235
+ const content = typeof value.content === 'string' ? value.content : null;
236
+ const createdAt = typeof value.createdAt === 'number' ? value.createdAt : null;
237
+ if (!id || content === null || createdAt === null || !isValidRole(role)) {
238
+ return [];
239
+ }
240
+ const attachments = Array.isArray(value.attachments)
241
+ ? value.attachments.filter(isValidAttachment)
242
+ : undefined;
243
+ const execResults = Array.isArray(value.execResults)
244
+ ? new Map((value.execResults as Array<[number, CodeExecResult]>).filter(
245
+ (entry): entry is [number, CodeExecResult] => Array.isArray(entry) && typeof entry[0] === 'number',
246
+ ))
247
+ : undefined;
248
+ return [{
249
+ id,
250
+ role,
251
+ content,
252
+ createdAt,
253
+ codeBlocks: value.codeBlocks as ChatMessage['codeBlocks'],
254
+ attachments: attachments && attachments.length > 0 ? attachments : undefined,
255
+ execResults,
256
+ }];
257
+ }
258
+
259
+ function isValidRole(value: unknown): value is ChatMessage['role'] {
260
+ return value === 'user' || value === 'assistant' || value === 'system';
261
+ }
262
+
263
+ function isValidAttachment(value: unknown): value is FileAttachment {
264
+ if (!value || typeof value !== 'object') return false;
265
+ const attachment = value as Record<string, unknown>;
266
+ return typeof attachment.id === 'string'
267
+ && typeof attachment.name === 'string'
268
+ && typeof attachment.type === 'string'
269
+ && typeof attachment.size === 'number';
270
+ }
271
+
272
+ export const createChatSlice: StateCreator<ChatSlice, [], [], ChatSlice> = (set, get) => ({
273
+ // Initial state
274
+ chatPanelVisible: loadStoredPanelVisible(),
275
+ chatMessages: loadStoredMessages(null),
276
+ chatStatus: 'idle',
277
+ chatStreamingContent: '',
278
+ chatActiveModel: loadStoredModel(null),
279
+ chatAutoExecute: loadStoredAutoExecute(),
280
+ chatError: null,
281
+ chatAbortController: null,
282
+ chatAttachments: [],
283
+ chatPendingPrompt: null,
284
+ chatPendingRepairRequest: null,
285
+ chatViewportScreenshot: null,
286
+ chatAuthToken: null,
287
+ chatHasPro: false,
288
+ chatUsage: null,
289
+ chatStorageUserId: null,
290
+
291
+ // Actions
292
+ setChatPanelVisible: (chatPanelVisible) => {
293
+ try { localStorage.setItem(PANEL_VISIBLE_STORAGE_KEY, String(chatPanelVisible)); } catch { /* ignore */ }
294
+ set({ chatPanelVisible });
295
+ },
296
+
297
+ toggleChatPanel: () => {
298
+ const next = !get().chatPanelVisible;
299
+ try { localStorage.setItem(PANEL_VISIBLE_STORAGE_KEY, String(next)); } catch { /* ignore */ }
300
+ set({ chatPanelVisible: next });
301
+ },
302
+
303
+ addChatMessage: (message) => {
304
+ const messages = trimChatMessages([...get().chatMessages, message]);
305
+ set({ chatMessages: messages, chatError: null });
306
+ persistMessages(messages, get().chatStorageUserId);
307
+ },
308
+
309
+ updateLastAssistantMessage: (content) => {
310
+ set({ chatStreamingContent: content });
311
+ },
312
+
313
+ finalizeAssistantMessage: (content) => {
314
+ const codeBlocks = extractCodeBlocks(content);
315
+ const id = crypto.randomUUID();
316
+ const message: ChatMessage = {
317
+ id,
318
+ role: 'assistant',
319
+ content,
320
+ createdAt: Date.now(),
321
+ codeBlocks: codeBlocks.length > 0 ? codeBlocks : undefined,
322
+ };
323
+ const messages = trimChatMessages([...get().chatMessages, message]);
324
+ set({
325
+ chatMessages: messages,
326
+ chatStreamingContent: '',
327
+ chatStatus: 'idle',
328
+ chatAbortController: null,
329
+ });
330
+ persistMessages(messages, get().chatStorageUserId);
331
+ return id;
332
+ },
333
+
334
+ setChatStatus: (chatStatus) => set({ chatStatus }),
335
+
336
+ setChatStreamingContent: (chatStreamingContent) => set({ chatStreamingContent }),
337
+
338
+ setChatActiveModel: (chatActiveModel) => {
339
+ const nextModel = coerceModelForEntitlement(chatActiveModel, get().chatHasPro);
340
+ try {
341
+ const key = getModelStorageKey(get().chatStorageUserId);
342
+ localStorage.setItem(key, nextModel);
343
+ } catch { /* ignore */ }
344
+ set({ chatActiveModel: nextModel });
345
+ },
346
+
347
+ setChatAutoExecute: (chatAutoExecute) => {
348
+ try { localStorage.setItem(AUTO_EXEC_STORAGE_KEY, String(chatAutoExecute)); } catch { /* ignore */ }
349
+ set({ chatAutoExecute });
350
+ },
351
+
352
+ setChatError: (chatError) => set({ chatError, chatStatus: chatError ? 'error' : 'idle' }),
353
+
354
+ setChatAbortController: (chatAbortController) => set({ chatAbortController }),
355
+
356
+ setCodeExecResult: (messageId, blockIndex, result) => {
357
+ const messages = get().chatMessages.map((msg) => {
358
+ if (msg.id !== messageId) return msg;
359
+ const execResults = new Map(msg.execResults ?? []);
360
+ execResults.set(blockIndex, result);
361
+ return { ...msg, execResults };
362
+ });
363
+ set({ chatMessages: messages });
364
+ persistMessages(messages, get().chatStorageUserId);
365
+ },
366
+
367
+ addChatAttachment: (attachment) => {
368
+ set({ chatAttachments: [...get().chatAttachments, attachment] });
369
+ },
370
+
371
+ removeChatAttachment: (attachmentId) => {
372
+ set({ chatAttachments: get().chatAttachments.filter((a) => a.id !== attachmentId) });
373
+ },
374
+
375
+ clearChatAttachments: () => set({ chatAttachments: [] }),
376
+
377
+ clearChatMessages: () => {
378
+ get().chatAbortController?.abort();
379
+ set({
380
+ chatMessages: [],
381
+ chatStatus: 'idle',
382
+ chatStreamingContent: '',
383
+ chatError: null,
384
+ chatAbortController: null,
385
+ chatAttachments: [],
386
+ chatPendingPrompt: null,
387
+ chatPendingRepairRequest: null,
388
+ chatViewportScreenshot: null,
389
+ });
390
+ try { localStorage.removeItem(getMessagesStorageKey(get().chatStorageUserId)); } catch { /* ignore */ }
391
+ },
392
+
393
+ queueChatPrompt: (chatPendingPrompt) => set({ chatPendingPrompt }),
394
+
395
+ consumeChatPendingPrompt: () => set({ chatPendingPrompt: null }),
396
+
397
+ queueChatRepairRequest: (chatPendingRepairRequest) => set({ chatPendingRepairRequest }),
398
+
399
+ consumeChatPendingRepairRequest: () => set({ chatPendingRepairRequest: null }),
400
+
401
+ setChatViewportScreenshot: (chatViewportScreenshot) => set({ chatViewportScreenshot }),
402
+
403
+ setChatAuthToken: (chatAuthToken) => set({ chatAuthToken }),
404
+
405
+ setChatHasPro: (chatHasPro) => {
406
+ const nextModel = coerceModelForEntitlement(get().chatActiveModel, chatHasPro);
407
+ try {
408
+ const key = getModelStorageKey(get().chatStorageUserId);
409
+ localStorage.setItem(key, nextModel);
410
+ } catch { /* ignore */ }
411
+ set({ chatHasPro, chatActiveModel: nextModel });
412
+ },
413
+
414
+ setChatUsage: (chatUsage) => set({ chatUsage }),
415
+
416
+ switchChatUserContext: (chatStorageUserId, chatHasPro, options) => {
417
+ const state = get();
418
+ state.chatAbortController?.abort();
419
+ if (options?.clearPersistedCurrent) {
420
+ try {
421
+ localStorage.removeItem(getMessagesStorageKey(state.chatStorageUserId));
422
+ } catch { /* ignore */ }
423
+ }
424
+ const restoredModel = coerceModelForEntitlement(
425
+ loadStoredModel(chatStorageUserId, state.chatActiveModel),
426
+ chatHasPro,
427
+ );
428
+ const restoredMessages = options?.restoreMessages === false
429
+ ? []
430
+ : loadStoredMessages(chatStorageUserId);
431
+ set({
432
+ chatStorageUserId,
433
+ chatHasPro,
434
+ chatActiveModel: restoredModel,
435
+ chatMessages: restoredMessages,
436
+ chatStatus: 'idle',
437
+ chatStreamingContent: '',
438
+ chatError: null,
439
+ chatAbortController: null,
440
+ chatAttachments: [],
441
+ chatPendingPrompt: null,
442
+ chatPendingRepairRequest: null,
443
+ chatViewportScreenshot: null,
444
+ chatUsage: null,
445
+ });
446
+ },
447
+
448
+ sendErrorFeedback: (code, error) => {
449
+ const feedbackMessage: ChatMessage = {
450
+ id: crypto.randomUUID(),
451
+ role: 'user',
452
+ content: buildErrorFeedbackContent(code, error),
453
+ createdAt: Date.now(),
454
+ };
455
+ const messages = trimChatMessages([...get().chatMessages, feedbackMessage]);
456
+ set({ chatMessages: messages, chatError: null });
457
+ persistMessages(messages, get().chatStorageUserId);
458
+ },
459
+ });
460
+
461
+ function formatRange(value: unknown): string {
462
+ if (!value || typeof value !== 'object') return 'unknown';
463
+ const from = (value as Record<string, unknown>).from;
464
+ const to = (value as Record<string, unknown>).to;
465
+ return typeof from === 'number' && typeof to === 'number'
466
+ ? `${from}..${to}`
467
+ : 'unknown';
468
+ }
@@ -0,0 +1,75 @@
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
+ import test from 'node:test';
6
+ import assert from 'node:assert/strict';
7
+ import { create } from 'zustand';
8
+ import { createScriptSlice, type ScriptSlice } from './scriptSlice.js';
9
+
10
+ test('assistant turn rollback restores the pre-turn script snapshot', () => {
11
+ const useScriptStore = create<ScriptSlice>()((...args) => createScriptSlice(...args));
12
+ const initialScript = `
13
+ const h = bim.create.project({ Name: "Tower" });
14
+ const storey = bim.create.addIfcBuildingStorey(h, { Name: "Level 0", Elevation: 0 });
15
+ `;
16
+ const appliedContents: string[] = [];
17
+
18
+ useScriptStore.getState().registerScriptEditorApplyAdapter({
19
+ apply: (nextContent) => {
20
+ appliedContents.push(nextContent);
21
+ },
22
+ undo: () => {},
23
+ redo: () => {},
24
+ });
25
+
26
+ useScriptStore.getState().setScriptEditorContent(initialScript);
27
+ const baseRevision = useScriptStore.getState().scriptEditorRevision;
28
+ useScriptStore.getState().beginAssistantScriptTurn();
29
+
30
+ const applyResult = useScriptStore.getState().applyScriptEditOps([{
31
+ opId: 'append-facade-fragment',
32
+ type: 'append',
33
+ baseRevision,
34
+ text: '\nconst facadeOnly = true;\n',
35
+ }], {
36
+ intent: 'repair',
37
+ });
38
+
39
+ assert.equal(applyResult.ok, true);
40
+ assert.match(useScriptStore.getState().scriptEditorContent, /facadeOnly/);
41
+
42
+ useScriptStore.getState().rollbackAssistantScriptTurn();
43
+
44
+ assert.equal(useScriptStore.getState().scriptEditorContent, initialScript);
45
+ assert.equal(useScriptStore.getState().scriptAssistantTurnSnapshot, null);
46
+ assert.equal(useScriptStore.getState().scriptEditorRevision, baseRevision);
47
+ assert.ok(appliedContents.some((content) => content === initialScript));
48
+ });
49
+
50
+ test('resetScriptEditorForNewChat clears the editor and detaches the active script', () => {
51
+ const useScriptStore = create<ScriptSlice>()((...args) => createScriptSlice(...args));
52
+ const appliedContents: string[] = [];
53
+
54
+ useScriptStore.getState().registerScriptEditorApplyAdapter({
55
+ apply: (nextContent) => {
56
+ appliedContents.push(nextContent);
57
+ },
58
+ undo: () => {},
59
+ redo: () => {},
60
+ });
61
+
62
+ const scriptId = useScriptStore.getState().createScript('Tower Script', 'const h = bim.create.project({ Name: "Tower" });');
63
+ useScriptStore.getState().setScriptError('Script execution failed');
64
+
65
+ useScriptStore.getState().resetScriptEditorForNewChat();
66
+
67
+ assert.equal(scriptId.length > 0, true);
68
+ assert.equal(useScriptStore.getState().activeScriptId, null);
69
+ assert.equal(useScriptStore.getState().scriptEditorContent, '');
70
+ assert.equal(useScriptStore.getState().scriptEditorDirty, false);
71
+ assert.equal(useScriptStore.getState().scriptExecutionState, 'idle');
72
+ assert.equal(useScriptStore.getState().scriptLastError, null);
73
+ assert.equal(useScriptStore.getState().scriptAssistantTurnSnapshot, null);
74
+ assert.deepEqual(appliedContents.at(-1), '');
75
+ });