@aleph-alpha/chat-kit 6.4.1 → 6.4.3

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 (33) hide show
  1. package/dist/{CkHistory.vue_vue_type_script_setup_true_lang-BvRIp3m6.js → CkHistory.vue_vue_type_script_setup_true_lang-Bv3FuKT4.js} +85 -35
  2. package/dist/chat-kit.css +63 -19
  3. package/dist/components/base/CkCodeEditor/CkCodeEditor.stories.d.ts.map +1 -1
  4. package/dist/components/base/CkCodeEditor/CkCodeEditor.vue.d.ts +2 -0
  5. package/dist/components/base/CkCodeEditor/CkCodeEditor.vue.d.ts.map +1 -1
  6. package/dist/components/base/CkCodeEditor/createCodeMirror.d.ts +5 -1
  7. package/dist/components/base/CkCodeEditor/createCodeMirror.d.ts.map +1 -1
  8. package/dist/components/base/CkCodeEditor/index.d.ts +1 -0
  9. package/dist/components/base/CkCodeEditor/index.d.ts.map +1 -1
  10. package/dist/components/base/CkCodeEditor/loadCodeMirror.d.ts +17 -0
  11. package/dist/components/base/CkCodeEditor/loadCodeMirror.d.ts.map +1 -0
  12. package/dist/components/base/CkCodeEditor/types.d.ts +25 -6
  13. package/dist/components/base/CkCodeEditor/types.d.ts.map +1 -1
  14. package/dist/components/base/index.d.ts +2 -2
  15. package/dist/components/base/index.d.ts.map +1 -1
  16. package/dist/components/index.d.ts +2 -2
  17. package/dist/components/index.d.ts.map +1 -1
  18. package/dist/components/index.js +3 -2
  19. package/dist/{createCodeMirror-BO-TWTzn.js → createCodeMirror-CN8xL8qU.js} +20 -10
  20. package/dist/index.d.ts +2 -2
  21. package/dist/index.d.ts.map +1 -1
  22. package/dist/index.js +3 -2
  23. package/package.json +2 -2
  24. package/src/components/base/CkCodeEditor/CkCodeEditor.spec.ts +99 -0
  25. package/src/components/base/CkCodeEditor/CkCodeEditor.stories.ts +10 -0
  26. package/src/components/base/CkCodeEditor/CkCodeEditor.vue +123 -34
  27. package/src/components/base/CkCodeEditor/createCodeMirror.ts +34 -13
  28. package/src/components/base/CkCodeEditor/index.ts +1 -0
  29. package/src/components/base/CkCodeEditor/loadCodeMirror.ts +28 -0
  30. package/src/components/base/CkCodeEditor/types.ts +30 -6
  31. package/src/components/base/index.ts +2 -1
  32. package/src/components/index.ts +2 -0
  33. package/src/index.ts +2 -0
@@ -114,22 +114,22 @@ function baseExtensions(options) {
114
114
  ...options.placeholder ? [placeholder(options.placeholder)] : []
115
115
  ];
116
116
  }
117
- async function createCodeMirror(options) {
117
+ function createCodeMirror(options) {
118
118
  const languageSlot = new Compartment();
119
119
  const readonlySlot = new Compartment();
120
120
  const lineNumbersSlot = new Compartment();
121
121
  const wrappingSlot = new Compartment();
122
122
  const badgeSlot = new Compartment();
123
123
  const lineSeparatorSlot = new Compartment();
124
- const support = await resolveLanguage(options.language);
125
124
  let languageRequestId = 0;
125
+ let destroyed = false;
126
126
  const view = new EditorView({
127
127
  parent: options.parent,
128
128
  state: EditorState.create({
129
129
  doc: options.doc,
130
130
  extensions: [
131
131
  ...baseExtensions(options),
132
- languageSlot.of(support ? [support] : []),
132
+ languageSlot.of([]),
133
133
  readonlySlot.of(readonlyState(options.readonly)),
134
134
  lineNumbersSlot.of(options.showLineNumbers ? lineNumbers() : []),
135
135
  wrappingSlot.of(options.lineWrapping ? EditorView.lineWrapping : []),
@@ -141,8 +141,20 @@ async function createCodeMirror(options) {
141
141
  })
142
142
  });
143
143
  function reconfigure(slot, extension) {
144
+ if (destroyed) return;
144
145
  view.dispatch({ effects: slot.reconfigure(extension) });
145
146
  }
147
+ async function setLanguage(language) {
148
+ const request = ++languageRequestId;
149
+ const next = await resolveLanguage(language);
150
+ if (request !== languageRequestId) return;
151
+ try {
152
+ reconfigure(languageSlot, next ? [next] : []);
153
+ } catch (error) {
154
+ console.warn(`[CkCodeEditor] Could not apply "${language}":`, error);
155
+ }
156
+ }
157
+ setLanguage(options.language).catch(() => void 0);
146
158
  return {
147
159
  view,
148
160
  getCode: () => readDoc(view.state),
@@ -158,12 +170,7 @@ async function createCodeMirror(options) {
158
170
  selection: { anchor: cursor }
159
171
  });
160
172
  },
161
- setLanguage: async (language) => {
162
- const request = ++languageRequestId;
163
- const next = await resolveLanguage(language);
164
- if (request !== languageRequestId) return;
165
- reconfigure(languageSlot, next ? [next] : []);
166
- },
173
+ setLanguage,
167
174
  setReadonly: (readonly) => {
168
175
  reconfigure(readonlySlot, readonlyState(readonly));
169
176
  },
@@ -177,7 +184,10 @@ async function createCodeMirror(options) {
177
184
  reconfigure(badgeSlot, rule ? badgeExtension(rule) : []);
178
185
  },
179
186
  focus: () => view.focus(),
180
- destroy: () => view.destroy()
187
+ destroy: () => {
188
+ destroyed = true;
189
+ view.destroy();
190
+ }
181
191
  };
182
192
  }
183
193
  export {
package/dist/index.d.ts CHANGED
@@ -6,8 +6,8 @@ export { useChat, useConversationSessions, useConversations, } from './composabl
6
6
  export type { ChatActions, ChatState, ConversationSession, CreateConversationInput, MessageHandler, MutationOptions, UseChatReturn, UseConversationSessionsReturn, UseConversationsOptions, UseConversationsReturn, } from './composables';
7
7
  export { SdkError, createDocumentUploadProvider, defaultDocumentUploadConfig, defaultEndpoints, useDocumentUploadWithDefaultConfig, } from './composables/fileUpload';
8
8
  export type { DocumentUploadConfig, DocumentUploadProvider, FileEntry, FileStatus, } from './composables/fileUpload';
9
- export { CkAgentMessage, CkAgentMessageHeader, CkHeaderActivityButton, CkCitation, CkCitationLabel, CkCitationCard, CkConversation, CkConversationLayout, CkCopyMessageButton, CkDropZoneOverlay, CkFeedbackButton, CkFeedbackDialog, CkFileItemsList, CkHistory, CkHistoryItem, CkHistoryWrapper, CkMarkdownRenderer, CkMessageButton, CkModelSelector, CkPromptSendButton, CkPromptMenuButton, CkPromptStopButton, CkPromptInput, CkPromptInputDisclaimer, CkRegenerateMessageButton, CkSendIcon, CkStopIcon, CkUserMessage, CkEditableUserMessage, CkMilkdownEditor, CkCodeEditor, } from './components';
10
- export type { CkAgentMessageHeaderProps, CkHeaderActivityButtonProps, CkHeaderActivityButtonEmits, CkCitationProps, CkCitationEmits, CkCitationSource, CkCitationLabelProps, CkCitationCardProps, CkCitationCardEmits, CkConversationLayoutProps, CkConversationProps, CkDropZoneOverlayProps, CkDropZoneOverlayEmits, CkDropZoneRejection, CkDropZoneRejectionReason, CkFeedbackButtonProps, CkFeedbackDialogEmits, CkFeedbackDialogProps, CkFeedbackDialogSubmitPayload, CkFeedbackReason, CkFeedbackSentiment, CkFeedbackValue, CkFileEntry, CkFileItemsListEmits, CkFileItemsListProps, CkHistoryEmits, CkHistoryItemEmits, CkHistoryItemProps, CkHistoryProps, CkHistoryWrapperProps, CkMarkdownElementClasses, CkMarkdownRendererProps, CkMarkdownRendererEmits, CkModelOption, CkModelSelectorProps, CkModelSelectorEmits, CkPromptMenuButtonProps, CkUserMessageProps, CkEditableUserMessageProps, CkEditableUserMessageEmits, CkMilkdownEditorEmits, CkMilkdownEditorExposed, CkMilkdownEditorProps, CkCodeEditorEmits, CkCodeEditorExposed, CkCodeEditorProps, } from './components';
9
+ export { CkAgentMessage, CkAgentMessageHeader, CkHeaderActivityButton, CkCitation, CkCitationLabel, CkCitationCard, CkConversation, CkConversationLayout, CkCopyMessageButton, CkDropZoneOverlay, CkFeedbackButton, CkFeedbackDialog, CkFileItemsList, CkHistory, CkHistoryItem, CkHistoryWrapper, CkMarkdownRenderer, CkMessageButton, CkModelSelector, CkPromptSendButton, CkPromptMenuButton, CkPromptStopButton, CkPromptInput, CkPromptInputDisclaimer, CkRegenerateMessageButton, CkSendIcon, CkStopIcon, CkUserMessage, CkEditableUserMessage, CkMilkdownEditor, CkCodeEditor, preloadCkCodeEditor, } from './components';
10
+ export type { CkAgentMessageHeaderProps, CkHeaderActivityButtonProps, CkHeaderActivityButtonEmits, CkCitationProps, CkCitationEmits, CkCitationSource, CkCitationLabelProps, CkCitationCardProps, CkCitationCardEmits, CkConversationLayoutProps, CkConversationProps, CkDropZoneOverlayProps, CkDropZoneOverlayEmits, CkDropZoneRejection, CkDropZoneRejectionReason, CkFeedbackButtonProps, CkFeedbackDialogEmits, CkFeedbackDialogProps, CkFeedbackDialogSubmitPayload, CkFeedbackReason, CkFeedbackSentiment, CkFeedbackValue, CkFileEntry, CkFileItemsListEmits, CkFileItemsListProps, CkHistoryEmits, CkHistoryItemEmits, CkHistoryItemProps, CkHistoryProps, CkHistoryWrapperProps, CkMarkdownElementClasses, CkMarkdownRendererProps, CkMarkdownRendererEmits, CkModelOption, CkModelSelectorProps, CkModelSelectorEmits, CkPromptMenuButtonProps, CkUserMessageProps, CkEditableUserMessageProps, CkEditableUserMessageEmits, CkMilkdownEditorEmits, CkMilkdownEditorExposed, CkMilkdownEditorProps, CkCodeEditorEmits, CkCodeEditorExposed, CkCodeEditorFadeSurface, CkCodeEditorProps, } from './components';
11
11
  export { formatRelativeTime, markdownToPlainText } from './helpers';
12
12
  export type { ActiveComparison, BaseMessage, ComparisonLane, Conversation, ConversationTree, MessageContent, MessageContentStatus, MessageMetadata, MessageReasoningContent, MessageRole, MessageStatus, MessageTextContent, MessageToolContent, TreeMessage, } from './types';
13
13
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAMA,OAAO,iBAAiB,CAAC;AAGzB,OAAO,EACL,KAAK,UAAU,EACf,KAAK,WAAW,EAChB,KAAK,YAAY,EACjB,KAAK,mBAAmB,EACxB,KAAK,UAAU,EACf,KAAK,eAAe,EACpB,KAAK,mBAAmB,EACxB,KAAK,WAAW,GACjB,MAAM,YAAY,CAAC;AASpB,OAAO,EACL,iBAAiB,EACjB,iBAAiB,EACjB,oBAAoB,EACpB,gBAAgB,EAChB,oBAAoB,EACpB,eAAe,GAChB,MAAM,YAAY,CAAC;AACpB,YAAY,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAGhD,OAAO,EACL,OAAO,EACP,uBAAuB,EACvB,gBAAgB,GACjB,MAAM,eAAe,CAAC;AACvB,YAAY,EACV,WAAW,EACX,SAAS,EACT,mBAAmB,EACnB,uBAAuB,EACvB,cAAc,EACd,eAAe,EACf,aAAa,EACb,6BAA6B,EAC7B,uBAAuB,EACvB,sBAAsB,GACvB,MAAM,eAAe,CAAC;AAEvB,OAAO,EACL,QAAQ,EACR,4BAA4B,EAC5B,2BAA2B,EAC3B,gBAAgB,EAChB,kCAAkC,GACnC,MAAM,0BAA0B,CAAC;AAClC,YAAY,EACV,oBAAoB,EACpB,sBAAsB,EACtB,SAAS,EACT,UAAU,GACX,MAAM,0BAA0B,CAAC;AAGlC,OAAO,EACL,cAAc,EACd,oBAAoB,EACpB,sBAAsB,EACtB,UAAU,EACV,eAAe,EACf,cAAc,EACd,cAAc,EACd,oBAAoB,EACpB,mBAAmB,EACnB,iBAAiB,EACjB,gBAAgB,EAChB,gBAAgB,EAChB,eAAe,EACf,SAAS,EACT,aAAa,EACb,gBAAgB,EAChB,kBAAkB,EAClB,eAAe,EACf,eAAe,EACf,kBAAkB,EAClB,kBAAkB,EAClB,kBAAkB,EAClB,aAAa,EACb,uBAAuB,EACvB,yBAAyB,EACzB,UAAU,EACV,UAAU,EACV,aAAa,EACb,qBAAqB,EACrB,gBAAgB,EAChB,YAAY,GACb,MAAM,cAAc,CAAC;AACtB,YAAY,EACV,yBAAyB,EACzB,2BAA2B,EAC3B,2BAA2B,EAC3B,eAAe,EACf,eAAe,EACf,gBAAgB,EAChB,oBAAoB,EACpB,mBAAmB,EACnB,mBAAmB,EACnB,yBAAyB,EACzB,mBAAmB,EACnB,sBAAsB,EACtB,sBAAsB,EACtB,mBAAmB,EACnB,yBAAyB,EACzB,qBAAqB,EACrB,qBAAqB,EACrB,qBAAqB,EACrB,6BAA6B,EAC7B,gBAAgB,EAChB,mBAAmB,EACnB,eAAe,EACf,WAAW,EACX,oBAAoB,EACpB,oBAAoB,EACpB,cAAc,EACd,kBAAkB,EAClB,kBAAkB,EAClB,cAAc,EACd,qBAAqB,EACrB,wBAAwB,EACxB,uBAAuB,EACvB,uBAAuB,EACvB,aAAa,EACb,oBAAoB,EACpB,oBAAoB,EACpB,uBAAuB,EACvB,kBAAkB,EAClB,0BAA0B,EAC1B,0BAA0B,EAC1B,qBAAqB,EACrB,uBAAuB,EACvB,qBAAqB,EACrB,iBAAiB,EACjB,mBAAmB,EACnB,iBAAiB,GAClB,MAAM,cAAc,CAAC;AAGtB,OAAO,EAAE,kBAAkB,EAAE,mBAAmB,EAAE,MAAM,WAAW,CAAC;AAGpE,YAAY,EACV,gBAAgB,EAChB,WAAW,EACX,cAAc,EACd,YAAY,EACZ,gBAAgB,EAChB,cAAc,EACd,oBAAoB,EACpB,eAAe,EACf,uBAAuB,EACvB,WAAW,EACX,aAAa,EACb,kBAAkB,EAClB,kBAAkB,EAClB,WAAW,GACZ,MAAM,SAAS,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAMA,OAAO,iBAAiB,CAAC;AAGzB,OAAO,EACL,KAAK,UAAU,EACf,KAAK,WAAW,EAChB,KAAK,YAAY,EACjB,KAAK,mBAAmB,EACxB,KAAK,UAAU,EACf,KAAK,eAAe,EACpB,KAAK,mBAAmB,EACxB,KAAK,WAAW,GACjB,MAAM,YAAY,CAAC;AASpB,OAAO,EACL,iBAAiB,EACjB,iBAAiB,EACjB,oBAAoB,EACpB,gBAAgB,EAChB,oBAAoB,EACpB,eAAe,GAChB,MAAM,YAAY,CAAC;AACpB,YAAY,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AAGhD,OAAO,EACL,OAAO,EACP,uBAAuB,EACvB,gBAAgB,GACjB,MAAM,eAAe,CAAC;AACvB,YAAY,EACV,WAAW,EACX,SAAS,EACT,mBAAmB,EACnB,uBAAuB,EACvB,cAAc,EACd,eAAe,EACf,aAAa,EACb,6BAA6B,EAC7B,uBAAuB,EACvB,sBAAsB,GACvB,MAAM,eAAe,CAAC;AAEvB,OAAO,EACL,QAAQ,EACR,4BAA4B,EAC5B,2BAA2B,EAC3B,gBAAgB,EAChB,kCAAkC,GACnC,MAAM,0BAA0B,CAAC;AAClC,YAAY,EACV,oBAAoB,EACpB,sBAAsB,EACtB,SAAS,EACT,UAAU,GACX,MAAM,0BAA0B,CAAC;AAGlC,OAAO,EACL,cAAc,EACd,oBAAoB,EACpB,sBAAsB,EACtB,UAAU,EACV,eAAe,EACf,cAAc,EACd,cAAc,EACd,oBAAoB,EACpB,mBAAmB,EACnB,iBAAiB,EACjB,gBAAgB,EAChB,gBAAgB,EAChB,eAAe,EACf,SAAS,EACT,aAAa,EACb,gBAAgB,EAChB,kBAAkB,EAClB,eAAe,EACf,eAAe,EACf,kBAAkB,EAClB,kBAAkB,EAClB,kBAAkB,EAClB,aAAa,EACb,uBAAuB,EACvB,yBAAyB,EACzB,UAAU,EACV,UAAU,EACV,aAAa,EACb,qBAAqB,EACrB,gBAAgB,EAChB,YAAY,EACZ,mBAAmB,GACpB,MAAM,cAAc,CAAC;AACtB,YAAY,EACV,yBAAyB,EACzB,2BAA2B,EAC3B,2BAA2B,EAC3B,eAAe,EACf,eAAe,EACf,gBAAgB,EAChB,oBAAoB,EACpB,mBAAmB,EACnB,mBAAmB,EACnB,yBAAyB,EACzB,mBAAmB,EACnB,sBAAsB,EACtB,sBAAsB,EACtB,mBAAmB,EACnB,yBAAyB,EACzB,qBAAqB,EACrB,qBAAqB,EACrB,qBAAqB,EACrB,6BAA6B,EAC7B,gBAAgB,EAChB,mBAAmB,EACnB,eAAe,EACf,WAAW,EACX,oBAAoB,EACpB,oBAAoB,EACpB,cAAc,EACd,kBAAkB,EAClB,kBAAkB,EAClB,cAAc,EACd,qBAAqB,EACrB,wBAAwB,EACxB,uBAAuB,EACvB,uBAAuB,EACvB,aAAa,EACb,oBAAoB,EACpB,oBAAoB,EACpB,uBAAuB,EACvB,kBAAkB,EAClB,0BAA0B,EAC1B,0BAA0B,EAC1B,qBAAqB,EACrB,uBAAuB,EACvB,qBAAqB,EACrB,iBAAiB,EACjB,mBAAmB,EACnB,uBAAuB,EACvB,iBAAiB,GAClB,MAAM,cAAc,CAAC;AAGtB,OAAO,EAAE,kBAAkB,EAAE,mBAAmB,EAAE,MAAM,WAAW,CAAC;AAGpE,YAAY,EACV,gBAAgB,EAChB,WAAW,EACX,cAAc,EACd,YAAY,EACZ,gBAAgB,EAChB,cAAc,EACd,oBAAoB,EACpB,eAAe,EACf,uBAAuB,EACvB,WAAW,EACX,aAAa,EACb,kBAAkB,EAClB,kBAAkB,EAClB,WAAW,GACZ,MAAM,SAAS,CAAC"}
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { u } from "./useMessageHandler-Bkzrdgfj.js";
2
2
  import { C, d, p, u as u2, b } from "./useChatKitLabels-DxW0N5U-.js";
3
3
  import { S, c, d as d2, f, u as u3, a, b as b2, e } from "./index-D0DQjdsq.js";
4
- import { _, a as a2, b as b3, c as c2, C as C2, d as d3, e as e2, f as f2, g, h, i, j, k, l, m, n, o, p as p2, q, r, s, t, u as u4, v, w, x, y, z, A, B, D, E } from "./CkHistory.vue_vue_type_script_setup_true_lang-BvRIp3m6.js";
4
+ import { _, a as a2, b as b3, c as c2, C as C2, d as d3, e as e2, f as f2, g, h, i, j, k, l, m, n, o, p as p2, q, r, s, t, u as u4, v, w, x, y, z, A, B, D, F, E } from "./CkHistory.vue_vue_type_script_setup_true_lang-Bv3FuKT4.js";
5
5
  import { m as m2 } from "./markdownToPlainText-DN_G-I6g.js";
6
6
  export {
7
7
  C as CHAT_KIT_LABELS,
@@ -41,8 +41,9 @@ export {
41
41
  d as defaultChatKitLabels,
42
42
  d2 as defaultDocumentUploadConfig,
43
43
  f as defaultEndpoints,
44
- E as formatRelativeTime,
44
+ F as formatRelativeTime,
45
45
  m2 as markdownToPlainText,
46
+ E as preloadCkCodeEditor,
46
47
  p as provideChatKitLabels,
47
48
  u3 as useChat,
48
49
  u2 as useChatKitLabels,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aleph-alpha/chat-kit",
3
- "version": "6.4.1",
3
+ "version": "6.4.3",
4
4
  "description": "Chat kit package for building chat interfaces with Vue",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -115,8 +115,8 @@
115
115
  "vue": "^3.5.27",
116
116
  "vue-tsc": "^3.2.4",
117
117
  "wait-on": "9.0.3",
118
- "@aleph-alpha/eslint-config-frontend": "0.7.1",
119
118
  "@aleph-alpha/prettier-config-frontend": "2.1.1",
119
+ "@aleph-alpha/eslint-config-frontend": "0.7.1",
120
120
  "@aleph-alpha/tsconfig-frontend": "0.7.3"
121
121
  },
122
122
  "scripts": {
@@ -1,8 +1,17 @@
1
1
  import { render, screen, waitFor } from '@testing-library/vue';
2
2
  import userEvent from '@testing-library/user-event';
3
+ import type { MockInstance } from 'vitest';
3
4
  import CkCodeEditor from './CkCodeEditor.vue';
4
5
  import { detectLineSeparator, resolveLanguage } from './createCodeMirror';
5
6
  import { matchBadgeRanges } from './badgeDecoration';
7
+ import { loadCodeMirror } from './loadCodeMirror';
8
+
9
+ // Wrapped so a test can make the lazy chunk fail; every other test gets the
10
+ // real module.
11
+ vi.mock('./loadCodeMirror', async (importOriginal) => {
12
+ const actual = await importOriginal<typeof import('./loadCodeMirror')>();
13
+ return { ...actual, loadCodeMirror: vi.fn(actual.loadCodeMirror) };
14
+ });
6
15
 
7
16
  // CodeMirror measures its own layout on construction; jsdom has none of the
8
17
  // geometry APIs it calls. The stubs stay in this spec rather than the global
@@ -273,6 +282,34 @@ describe('CkCodeEditor', () => {
273
282
  ).toBe('0px');
274
283
  });
275
284
 
285
+ it('fades into the design surfaces by default', async () => {
286
+ const { container } = await renderEditor({ modelValue: 'a\nb' });
287
+
288
+ const mask = container.querySelector('.ui-fade-mask') as HTMLElement;
289
+ expect(mask.style.getPropertyValue('--ck-code-fade-top')).toBe(
290
+ 'var(--background-surface-secondary)',
291
+ );
292
+ expect(mask.style.getPropertyValue('--ck-code-fade-bottom')).toBe(
293
+ 'var(--background-surface-floating)',
294
+ );
295
+ });
296
+
297
+ it('fades each edge into the surface its prop names', async () => {
298
+ const { container } = await renderEditor({
299
+ modelValue: 'a\nb',
300
+ fadeTopBackground: 'modal',
301
+ fadeBottomBackground: 'primary',
302
+ });
303
+
304
+ const mask = container.querySelector('.ui-fade-mask') as HTMLElement;
305
+ expect(mask.style.getPropertyValue('--ck-code-fade-top')).toBe(
306
+ 'var(--background-surface-modal)',
307
+ );
308
+ expect(mask.style.getPropertyValue('--ck-code-fade-bottom')).toBe(
309
+ 'var(--background-surface-primary)',
310
+ );
311
+ });
312
+
276
313
  it('omits the fade mask when `scrollFade` is off', async () => {
277
314
  const { container } = await renderEditor({
278
315
  modelValue: 'a\nb',
@@ -325,6 +362,68 @@ describe('CkCodeEditor', () => {
325
362
  const late = await renderEditor({ modelValue: 'y' });
326
363
  expect(() => late.unmount()).not.toThrow();
327
364
  });
365
+
366
+ describe('when CodeMirror fails to load', () => {
367
+ let logError: MockInstance;
368
+
369
+ beforeEach(() => {
370
+ vi.mocked(loadCodeMirror).mockRejectedValueOnce(
371
+ new Error('Failed to fetch dynamically imported module'),
372
+ );
373
+ logError = vi
374
+ .spyOn(globalThis.console, 'error')
375
+ .mockImplementation(() => undefined);
376
+ });
377
+
378
+ afterEach(() => {
379
+ logError.mockRestore();
380
+ });
381
+
382
+ async function renderFallback(
383
+ props: InstanceType<typeof CkCodeEditor>['$props'] = {},
384
+ ) {
385
+ const utils = render(CkCodeEditor, { props });
386
+ const textarea = await waitFor(() => {
387
+ const found = utils.container.querySelector(
388
+ '.ck-code-editor--fallback textarea',
389
+ );
390
+ expect(found).toBeInTheDocument();
391
+ return found as HTMLTextAreaElement;
392
+ });
393
+ return { ...utils, textarea };
394
+ }
395
+
396
+ it('falls back to a textarea with the value, placeholder and label', async () => {
397
+ const { textarea } = await renderFallback({
398
+ modelValue: 'body',
399
+ placeholder: 'Write the skill',
400
+ ariaLabel: 'Skill body',
401
+ });
402
+
403
+ expect(textarea.value).toBe('body');
404
+ expect(textarea.placeholder).toBe('Write the skill');
405
+ expect(textarea.getAttribute('aria-label')).toBe('Skill body');
406
+ expect(logError).toHaveBeenCalled();
407
+ });
408
+
409
+ it('keeps v-model working through the textarea', async () => {
410
+ const { textarea, emitted } = await renderFallback({ modelValue: '' });
411
+
412
+ await userEvent.type(textarea, 'hi');
413
+
414
+ const updates = emitted()['update:modelValue'] as string[][];
415
+ expect(updates.at(-1)?.[0]).toBe('hi');
416
+ });
417
+
418
+ it('locks the textarea when readonly', async () => {
419
+ const { textarea } = await renderFallback({
420
+ modelValue: 'x',
421
+ readonly: true,
422
+ });
423
+
424
+ expect(textarea.readOnly).toBe(true);
425
+ });
426
+ });
328
427
  });
329
428
 
330
429
  describe('resolveLanguage', () => {
@@ -58,6 +58,16 @@ const meta: Meta<typeof CkCodeEditor> = {
58
58
  lineWrapping: { control: 'boolean' },
59
59
  autofocus: { control: 'boolean' },
60
60
  scrollFade: { control: 'boolean' },
61
+ fadeTopBackground: {
62
+ control: 'inline-radio',
63
+ options: ['primary', 'secondary', 'floating', 'modal'],
64
+ description: 'Surface the top scroll fade dissolves into.',
65
+ },
66
+ fadeBottomBackground: {
67
+ control: 'inline-radio',
68
+ options: ['primary', 'secondary', 'floating', 'modal'],
69
+ description: 'Surface the bottom scroll fade dissolves into.',
70
+ },
61
71
  // A regex plus a callback cannot be typed into a control, so the rule is
62
72
  // offered as named presets instead. Without this, badges could only be seen
63
73
  // on the one story that hard-codes a rule.
@@ -11,6 +11,7 @@ import {
11
11
  import { UiButton, UiFadeMask, UiIcon } from '@aleph-alpha/ui-library';
12
12
  import { useChatKitLabels } from '../../../handlers/useChatKitLabels';
13
13
  import type { CodeMirrorHandle } from './createCodeMirror';
14
+ import { loadCodeMirror } from './loadCodeMirror';
14
15
  import type { CkCodeEditorEmits, CkCodeEditorProps } from './types';
15
16
 
16
17
  defineOptions({ name: 'CkCodeEditor' });
@@ -30,6 +31,8 @@ const props = withDefaults(defineProps<CkCodeEditorProps>(), {
30
31
  maxHeight: undefined,
31
32
  autofocus: false,
32
33
  scrollFade: true,
34
+ fadeTopBackground: 'secondary',
35
+ fadeBottomBackground: 'floating',
33
36
  badge: undefined,
34
37
  writeToClipboard: undefined,
35
38
  });
@@ -39,8 +42,12 @@ const emit = defineEmits<CkCodeEditorEmits>();
39
42
  const labels = useChatKitLabels();
40
43
  const rootRef = useTemplateRef<HTMLDivElement>('rootRef');
41
44
  const hostRef = useTemplateRef<HTMLDivElement>('hostRef');
45
+ const fallbackRef = useTemplateRef<HTMLTextAreaElement>('fallbackRef');
42
46
  const isLocked = computed(() => props.readonly || props.disabled);
43
47
  const isReady = ref(false);
48
+ // CodeMirror could not be loaded or built; a plain textarea stands in so the
49
+ // field can still be typed into.
50
+ const isFallback = ref(false);
44
51
  const isCopied = ref(false);
45
52
 
46
53
  // `UiCodeBlock` labels the block with the filename when there is one and hides
@@ -84,8 +91,11 @@ const fadeProps = computed(() => {
84
91
  const down = canScrollDown.value;
85
92
  return {
86
93
  position: edgeToFade(up, down),
87
- background: 'secondary',
88
- style: { '--ui-fade-mask-height': up || down ? FADE_HEIGHT : '0px' },
94
+ style: {
95
+ '--ui-fade-mask-height': up || down ? FADE_HEIGHT : '0px',
96
+ '--ck-code-fade-top': `var(--background-surface-${props.fadeTopBackground})`,
97
+ '--ck-code-fade-bottom': `var(--background-surface-${props.fadeBottomBackground})`,
98
+ },
89
99
  };
90
100
  });
91
101
 
@@ -125,19 +135,16 @@ function handleChange(value: string): void {
125
135
  emit('update:modelValue', value);
126
136
  }
127
137
 
128
- onMounted(async () => {
129
- const parent = hostRef.value;
130
- if (!parent) return;
131
-
132
- // Lazy: CodeMirror and its grammars load only once an editor is shown.
133
- const { createCodeMirror } = await import('./createCodeMirror');
138
+ async function mountEditor(parent: HTMLElement): Promise<void> {
139
+ // Lazy: CodeMirror loads only once an editor is shown. The grammar follows in
140
+ // the background, so a slow one delays the colours, not the editor.
141
+ const { createCodeMirror } = await loadCodeMirror();
134
142
  if (destroyed) return;
135
143
 
136
- const initialCode = props.modelValue;
137
- lastKnownCode = initialCode;
138
- const handle = await createCodeMirror({
144
+ lastKnownCode = props.modelValue;
145
+ const handle = createCodeMirror({
139
146
  parent,
140
- doc: initialCode,
147
+ doc: props.modelValue,
141
148
  language: props.language,
142
149
  placeholder: props.placeholder,
143
150
  ariaLabel: props.ariaLabel,
@@ -149,20 +156,39 @@ onMounted(async () => {
149
156
  onFocus: () => emit('focus'),
150
157
  });
151
158
 
152
- // Unmounted while the grammar was loading.
153
- if (destroyed) {
154
- handle.destroy();
155
- return;
156
- }
157
-
158
159
  editor = handle;
159
- // Props may have moved on while the grammar loaded; catch up.
160
- if (props.modelValue !== initialCode) handle.setCode(props.modelValue);
161
160
  watchScrolling(handle.view.scrollDOM, handle.view.contentDOM);
162
161
  isReady.value = true;
163
162
  if (props.autofocus) handle.focus();
163
+ }
164
+
165
+ onMounted(async () => {
166
+ const parent = hostRef.value;
167
+ if (!parent) return;
168
+
169
+ try {
170
+ await mountEditor(parent);
171
+ } catch (error) {
172
+ // A stale or unreachable chunk, or a CodeMirror setup error such as
173
+ // duplicate `@codemirror/state` copies. Left alone, the header would sit
174
+ // over an empty body with no way to type.
175
+ // eslint-disable-next-line no-console
176
+ console.error('[CkCodeEditor] Falling back to a plain textarea:', error);
177
+ if (destroyed) return;
178
+ editor?.destroy();
179
+ editor = undefined;
180
+ isFallback.value = true;
181
+ if (props.autofocus) {
182
+ await nextTick();
183
+ fallbackRef.value?.focus();
184
+ }
185
+ }
164
186
  });
165
187
 
188
+ function onFallbackInput(event: Event): void {
189
+ handleChange((event.target as HTMLTextAreaElement).value);
190
+ }
191
+
166
192
  watch(
167
193
  () => props.modelValue,
168
194
  (next) => {
@@ -234,7 +260,8 @@ function onFocusOut(event: FocusEvent): void {
234
260
  }
235
261
 
236
262
  function focus(): void {
237
- editor?.focus();
263
+ if (editor) editor.focus();
264
+ else fallbackRef.value?.focus();
238
265
  }
239
266
 
240
267
  function getCode(): string {
@@ -250,6 +277,7 @@ defineExpose({ focus, getCode });
250
277
  :class="[
251
278
  'ck-code-editor ck-code-surface border-border-surface-default bg-background-input-default min-w-0 overflow-hidden rounded-xl border',
252
279
  isReady && 'ck-code-editor--ready',
280
+ isFallback && 'ck-code-editor--fallback',
253
281
  isLocked && 'ck-code-editor--readonly',
254
282
  disabled && 'ck-code-editor--disabled',
255
283
  ]"
@@ -283,7 +311,22 @@ defineExpose({ focus, getCode });
283
311
  ref="hostRef"
284
312
  class="ck-code-editor__host"
285
313
  :style="maxHeight ? { '--ck-code-max-height': maxHeight } : undefined"
286
- />
314
+ >
315
+ <textarea
316
+ v-if="isFallback"
317
+ ref="fallbackRef"
318
+ class="ck-code-editor__fallback"
319
+ :value="modelValue"
320
+ :placeholder="placeholder"
321
+ :aria-label="ariaLabel"
322
+ :readonly="readonly"
323
+ :disabled="disabled"
324
+ :wrap="lineWrapping ? 'soft' : 'off'"
325
+ spellcheck="false"
326
+ @input="onFallbackInput"
327
+ @focus="emit('focus')"
328
+ />
329
+ </div>
287
330
  </component>
288
331
  </div>
289
332
  </template>
@@ -296,19 +339,15 @@ defineExpose({ focus, getCode });
296
339
  * `theme.ts`, because `CkMilkdownEditor`'s code blocks use that theme too and
297
340
  * must keep matching the rendered block. */
298
341
  .ck-code-editor {
342
+ /* `theme.ts` paints the editor from these; transparent lets the root's
343
+ * input surface show through. */
299
344
  --ck-cm-bg: transparent;
300
345
  --ck-cm-foreground: oklch(var(--content-on-surface-primary));
301
346
  --ck-cm-line-number: oklch(var(--content-on-surface-muted));
302
347
  }
303
348
 
304
- /* The editor fills the panel; `:deep` is needed because CodeMirror builds its
305
- * own DOM, which scoped styles do not reach. */
306
- .ck-code-editor__host :deep(.cm-editor) {
307
- background: var(--ck-cm-bg);
308
- color: var(--ck-cm-foreground);
309
- }
310
-
311
- /* Same declarations as the `body-14-regular` typography utility. */
349
+ /* `:deep` is needed below because CodeMirror builds its own DOM, which scoped
350
+ * styles do not reach. Same declarations as the `body-14-regular` typography utility. */
312
351
  .ck-code-editor__host :deep(.cm-scroller) {
313
352
  max-height: var(--ck-code-max-height, none);
314
353
  font-family: Saans, sans-serif;
@@ -343,14 +382,64 @@ defineExpose({ focus, getCode });
343
382
  font-weight: 500;
344
383
  }
345
384
 
385
+ /* A column so the stand-in below grows into a host a consumer has stretched
386
+ * (as a fixed-height dialog does), rather than sitting at its minimum height
387
+ * at the top of an empty panel. */
388
+ .ck-code-editor--fallback .ck-code-editor__host {
389
+ display: flex;
390
+ flex-direction: column;
391
+ }
392
+
393
+ /* Stand-in for when CodeMirror fails to load: same text metrics and insets as
394
+ * the editor without a gutter, so the field reads as the same surface. Where
395
+ * supported, it grows with its content the way the editor does. */
396
+ .ck-code-editor__fallback {
397
+ display: block;
398
+ flex: 1 1 auto;
399
+ width: 100%;
400
+ min-height: 5rem;
401
+ field-sizing: content;
402
+ max-height: var(--ck-code-max-height, none);
403
+ resize: none;
404
+ border: 0;
405
+ outline: none;
406
+ padding: 0.75rem 1.25rem 1rem 1rem;
407
+ background: var(--ck-cm-bg);
408
+ color: var(--ck-cm-foreground);
409
+ font-family: Saans, sans-serif;
410
+ font-size: 0.875rem;
411
+ font-weight: 400;
412
+ line-height: 1.25rem;
413
+ }
414
+
415
+ .ck-code-editor__fallback::placeholder,
346
416
  .ck-code-editor__host :deep(.cm-placeholder) {
347
417
  color: oklch(var(--content-on-surface-muted));
348
418
  }
349
419
 
350
- /* Nothing is being edited, so the active-line band would only be noise. */
351
- .ck-code-editor--readonly .ck-code-editor__host :deep(.cm-activeLine),
352
- .ck-code-editor--readonly .ck-code-editor__host :deep(.cm-activeLineGutter) {
353
- background: transparent;
420
+ /* Each band fades into its own surface (`fadeTopBackground` /
421
+ * `fadeBottomBackground`), whereas `UiFadeMask`'s `background` sets one for
422
+ * both and only knows `primary` and `secondary`. The gradient, edges and band
423
+ * height stay the mask's own. `[data-background]` outranks the mask's
424
+ * per-surface rule regardless of stylesheet order. */
425
+ .ck-code-editor__body
426
+ :deep(.ui-fade-mask__band[data-background][data-position='top']) {
427
+ --ui-fade-mask-surface: var(--ck-code-fade-top);
428
+ /* The mask turns its top band opaque halfway down on desktop, which left a
429
+ * solid strip under the header; mirror the bottom band's stops instead. */
430
+ --ui-fade-mask-stop: 20%;
431
+ }
432
+
433
+ .ck-code-editor__body
434
+ :deep(.ui-fade-mask__band[data-background][data-position='bottom']) {
435
+ --ui-fade-mask-surface: var(--ck-code-fade-bottom);
436
+ }
437
+
438
+ @media (min-width: 768px) {
439
+ .ck-code-editor__body
440
+ :deep(.ui-fade-mask__band[data-background][data-position='top']) {
441
+ --ui-fade-mask-stop: 100%;
442
+ }
354
443
  }
355
444
 
356
445
  .ck-code-editor--disabled {
@@ -151,10 +151,14 @@ function baseExtensions(options: CreateCodeMirrorOptions): Extension[] {
151
151
  /**
152
152
  * Build a CodeMirror view themed like `UiCodeBlock`. Loaded lazily by
153
153
  * `CkCodeEditor` so CodeMirror stays out of the package's main chunk.
154
+ *
155
+ * The view is built at once, as plain text, and the grammar is applied when it
156
+ * arrives: the placeholder, gutter and content must not wait on a network
157
+ * request that may be slow or never finish. Only the syntax colours do.
154
158
  */
155
- export async function createCodeMirror(
159
+ export function createCodeMirror(
156
160
  options: CreateCodeMirrorOptions,
157
- ): Promise<CodeMirrorHandle> {
161
+ ): CodeMirrorHandle {
158
162
  // One set per view: a compartment is the key a reconfigure targets, so
159
163
  // sharing them across editors would make the instances interfere.
160
164
  const languageSlot = new Compartment();
@@ -164,9 +168,10 @@ export async function createCodeMirror(
164
168
  const badgeSlot = new Compartment();
165
169
  const lineSeparatorSlot = new Compartment();
166
170
 
167
- const support = await resolveLanguage(options.language);
168
171
  // Guards against out-of-order grammar loads; see `setLanguage` below.
169
172
  let languageRequestId = 0;
173
+ // A grammar can land after the view is gone; it must not touch it then.
174
+ let destroyed = false;
170
175
 
171
176
  const view = new EditorView({
172
177
  parent: options.parent,
@@ -174,7 +179,7 @@ export async function createCodeMirror(
174
179
  doc: options.doc,
175
180
  extensions: [
176
181
  ...baseExtensions(options),
177
- languageSlot.of(support ? [support] : []),
182
+ languageSlot.of([]),
178
183
  readonlySlot.of(readonlyState(options.readonly)),
179
184
  lineNumbersSlot.of(options.showLineNumbers ? lineNumbers() : []),
180
185
  wrappingSlot.of(options.lineWrapping ? EditorView.lineWrapping : []),
@@ -187,9 +192,29 @@ export async function createCodeMirror(
187
192
  });
188
193
 
189
194
  function reconfigure(slot: Compartment, extension: Extension): void {
195
+ if (destroyed) return;
190
196
  view.dispatch({ effects: slot.reconfigure(extension) });
191
197
  }
192
198
 
199
+ async function setLanguage(language: string): Promise<void> {
200
+ // Grammars load over the network and out of order, so a slow one must not
201
+ // land on top of a language chosen after it.
202
+ const request = ++languageRequestId;
203
+ const next = await resolveLanguage(language);
204
+ if (request !== languageRequestId) return;
205
+ try {
206
+ reconfigure(languageSlot, next ? [next] : []);
207
+ } catch (error) {
208
+ // Typically a grammar built against another `@codemirror/state` copy.
209
+ // The editor keeps working as plain text; say why the colours are gone.
210
+ // eslint-disable-next-line no-console
211
+ console.warn(`[CkCodeEditor] Could not apply "${language}":`, error);
212
+ }
213
+ }
214
+
215
+ // Nothing waits on this; a failed grammar already resolves to plain text.
216
+ setLanguage(options.language).catch(() => undefined);
217
+
193
218
  return {
194
219
  view,
195
220
  getCode: () => readDoc(view.state),
@@ -208,14 +233,7 @@ export async function createCodeMirror(
208
233
  selection: { anchor: cursor },
209
234
  });
210
235
  },
211
- setLanguage: async (language) => {
212
- // Grammars load over the network and out of order, so a slow one must not
213
- // land on top of a language chosen after it.
214
- const request = ++languageRequestId;
215
- const next = await resolveLanguage(language);
216
- if (request !== languageRequestId) return;
217
- reconfigure(languageSlot, next ? [next] : []);
218
- },
236
+ setLanguage,
219
237
  setReadonly: (readonly) => {
220
238
  reconfigure(readonlySlot, readonlyState(readonly));
221
239
  },
@@ -229,6 +247,9 @@ export async function createCodeMirror(
229
247
  reconfigure(badgeSlot, rule ? badgeExtension(rule) : []);
230
248
  },
231
249
  focus: () => view.focus(),
232
- destroy: () => view.destroy(),
250
+ destroy: () => {
251
+ destroyed = true;
252
+ view.destroy();
253
+ },
233
254
  };
234
255
  }
@@ -1,2 +1,3 @@
1
1
  export { default as CkCodeEditor } from './CkCodeEditor.vue';
2
+ export { preloadCkCodeEditor } from './loadCodeMirror';
2
3
  export type * from './types';
@@ -0,0 +1,28 @@
1
+ import type * as CodeMirrorModule from './createCodeMirror';
2
+
3
+ let pending: Promise<typeof CodeMirrorModule> | undefined;
4
+
5
+ /**
6
+ * Import the CodeMirror chunk once and share the result.
7
+ *
8
+ * A failed import is forgotten rather than cached, so the next editor to mount
9
+ * (or the next `preloadCkCodeEditor` call) tries again instead of inheriting the
10
+ * failure.
11
+ */
12
+ export function loadCodeMirror(): Promise<typeof CodeMirrorModule> {
13
+ pending ??= import('./createCodeMirror').catch((error: unknown) => {
14
+ pending = undefined;
15
+ throw error;
16
+ });
17
+ return pending;
18
+ }
19
+
20
+ /**
21
+ * Start fetching `CkCodeEditor`'s CodeMirror chunk ahead of time, for example
22
+ * when a view that will open an editor is shown. The editor then appears
23
+ * without waiting on the network. Safe to call repeatedly; failures are
24
+ * swallowed here and surface, if still failing, when an editor mounts.
25
+ */
26
+ export function preloadCkCodeEditor(): void {
27
+ loadCodeMirror().catch(() => undefined);
28
+ }