@aleph-alpha/chat-kit 6.4.0 → 6.4.2

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/dist/{CkHistory.vue_vue_type_script_setup_true_lang-B0WUzTHa.js → CkHistory.vue_vue_type_script_setup_true_lang-wu1Zrvgc.js} +76 -14
  2. package/dist/adapters/responses-api.js +1 -1
  3. package/dist/chat-kit.css +101 -18
  4. package/dist/{code-palette-DH_G7mZ5.js → code-palette-BWrZDi47.js} +71 -35
  5. package/dist/components/base/CkCodeEditor/CkCodeEditor.stories.d.ts +2 -1
  6. package/dist/components/base/CkCodeEditor/CkCodeEditor.stories.d.ts.map +1 -1
  7. package/dist/components/base/CkCodeEditor/CkCodeEditor.vue.d.ts +3 -0
  8. package/dist/components/base/CkCodeEditor/CkCodeEditor.vue.d.ts.map +1 -1
  9. package/dist/components/base/CkCodeEditor/badgeDecoration.d.ts +2 -2
  10. package/dist/components/base/CkCodeEditor/createCodeMirror.d.ts +15 -1
  11. package/dist/components/base/CkCodeEditor/createCodeMirror.d.ts.map +1 -1
  12. package/dist/components/base/CkCodeEditor/types.d.ts +37 -6
  13. package/dist/components/base/CkCodeEditor/types.d.ts.map +1 -1
  14. package/dist/components/base/CkMarkdownRenderer/CkMarkdownRenderer.stories.d.ts +7 -0
  15. package/dist/components/base/CkMarkdownRenderer/CkMarkdownRenderer.stories.d.ts.map +1 -1
  16. package/dist/components/base/CkMarkdownRenderer/CkMarkdownRenderer.vue.d.ts +9 -0
  17. package/dist/components/base/CkMarkdownRenderer/CkMarkdownRenderer.vue.d.ts.map +1 -1
  18. package/dist/components/base/CkMarkdownRenderer/types.d.ts +13 -0
  19. package/dist/components/base/CkMarkdownRenderer/types.d.ts.map +1 -1
  20. package/dist/components/base/index.d.ts +1 -1
  21. package/dist/components/base/index.d.ts.map +1 -1
  22. package/dist/components/index.d.ts +1 -1
  23. package/dist/components/index.d.ts.map +1 -1
  24. package/dist/components/index.js +1 -1
  25. package/dist/composables/index.js +1 -1
  26. package/dist/{createCodeMirror-5iswYetS.js → createCodeMirror-BO-TWTzn.js} +27 -9
  27. package/dist/{createCrepeEditor-C-AQ1w_e.js → createCrepeEditor-BGSlwmKn.js} +4 -4
  28. package/dist/handlers/index.js +2 -2
  29. package/dist/helpers/codemirror/theme.d.ts.map +1 -1
  30. package/dist/helpers/unified/remarkBreaks.d.ts +15 -0
  31. package/dist/helpers/unified/remarkBreaks.d.ts.map +1 -0
  32. package/dist/{index-DLjsqQHx.js → index--Yonjzfi.js} +3 -3
  33. package/dist/{index-Mh3WUU-6.js → index-2YPxXIaf.js} +1 -1
  34. package/dist/{index-CWp_t8Wl.js → index-B4uqe-fL.js} +2 -2
  35. package/dist/{index-hEJ_Bavx.js → index-Bfo4U-Al.js} +2 -2
  36. package/dist/{index-g2X42srv.js → index-Bw39pbRL.js} +3 -3
  37. package/dist/{index-CIF4qQzR.js → index-CGEgwupi.js} +4 -4
  38. package/dist/{index-Dbi75pKH.js → index-CZmdzLaU.js} +3 -3
  39. package/dist/{index-CR2kGrpY.js → index-CeaT_FBl.js} +3 -3
  40. package/dist/{index-DfLCrnVq.js → index-CefLTKmi.js} +2 -2
  41. package/dist/{index-CLdXUpL8.js → index-CfVHzirA.js} +5 -5
  42. package/dist/{index-D0IZW-Up.js → index-CrBanggA.js} +2 -2
  43. package/dist/{index-BlIc9r0r.js → index-Cx5ThXzs.js} +3 -3
  44. package/dist/{index-CZFx44C7.js → index-D0DQjdsq.js} +2 -2
  45. package/dist/{index-DPgw0Cf_.js → index-DH9e_hqJ.js} +2 -2
  46. package/dist/{index-hbS5PDHj.js → index-DTomeh-p.js} +3 -3
  47. package/dist/{index-GpSpJ3ti.js → index-DZGfsazg.js} +3 -3
  48. package/dist/{index-QAs789O5.js → index-DlLnTKv3.js} +2 -2
  49. package/dist/{index-ABUMl7H3.js → index-DqUiBDRo.js} +3 -3
  50. package/dist/{index-CFGO7LRz.js → index-GDUM7K5V.js} +2 -2
  51. package/dist/{index-Db5-PJG-.js → index-Hro6EPZS.js} +2 -2
  52. package/dist/{index-CG8e3146.js → index-KgumaL1c.js} +1 -1
  53. package/dist/{index-XZeturb3.js → index-TemHfU1M.js} +3 -3
  54. package/dist/{index-DKwEe_Tu.js → index-g_Bs6dNr.js} +4 -4
  55. package/dist/{index-S4ylc8JK.js → index-oO_X5wU0.js} +3 -3
  56. package/dist/{index-BqWO6mvz.js → index-ptbBQ53d.js} +4 -4
  57. package/dist/index.d.ts +1 -1
  58. package/dist/index.d.ts.map +1 -1
  59. package/dist/index.js +5 -5
  60. package/dist/{markdownToPlainText-BOtlQ_BY.js → markdownToPlainText-DN_G-I6g.js} +1 -1
  61. package/dist/{useChatKitLabels-CatQSpfL.js → useChatKitLabels-DxW0N5U-.js} +1 -1
  62. package/dist/{useMessageHandler-nnsort0-.js → useMessageHandler-Bkzrdgfj.js} +1 -1
  63. package/package.json +1 -1
  64. package/src/components/base/CkCodeEditor/CkCodeEditor.spec.ts +128 -1
  65. package/src/components/base/CkCodeEditor/CkCodeEditor.stories.ts +13 -1
  66. package/src/components/base/CkCodeEditor/CkCodeEditor.vue +156 -19
  67. package/src/components/base/CkCodeEditor/badgeDecoration.ts +2 -2
  68. package/src/components/base/CkCodeEditor/createCodeMirror.ts +45 -8
  69. package/src/components/base/CkCodeEditor/types.ts +42 -6
  70. package/src/components/base/CkMarkdownRenderer/CkMarkdownRenderer.spec.ts +40 -0
  71. package/src/components/base/CkMarkdownRenderer/CkMarkdownRenderer.stories.ts +31 -0
  72. package/src/components/base/CkMarkdownRenderer/CkMarkdownRenderer.vue +3 -0
  73. package/src/components/base/CkMarkdownRenderer/types.ts +13 -0
  74. package/src/components/base/CkMilkdownEditor/milkdown-theme.css +6 -3
  75. package/src/components/base/index.ts +1 -0
  76. package/src/components/index.ts +1 -0
  77. package/src/helpers/codemirror/code-palette.css +23 -3
  78. package/src/helpers/codemirror/theme.ts +44 -8
  79. package/src/helpers/unified/remarkBreaks.ts +32 -0
  80. package/src/index.ts +1 -0
@@ -1,13 +1,14 @@
1
1
  <script setup lang="ts">
2
2
  import {
3
3
  computed,
4
+ nextTick,
4
5
  onBeforeUnmount,
5
6
  onMounted,
6
7
  ref,
7
8
  useTemplateRef,
8
9
  watch,
9
10
  } from 'vue';
10
- import { UiButton, UiIcon } from '@aleph-alpha/ui-library';
11
+ import { UiButton, UiFadeMask, UiIcon } from '@aleph-alpha/ui-library';
11
12
  import { useChatKitLabels } from '../../../handlers/useChatKitLabels';
12
13
  import type { CodeMirrorHandle } from './createCodeMirror';
13
14
  import type { CkCodeEditorEmits, CkCodeEditorProps } from './types';
@@ -28,6 +29,9 @@ const props = withDefaults(defineProps<CkCodeEditorProps>(), {
28
29
  lineWrapping: false,
29
30
  maxHeight: undefined,
30
31
  autofocus: false,
32
+ scrollFade: true,
33
+ fadeTopBackground: 'secondary',
34
+ fadeBottomBackground: 'floating',
31
35
  badge: undefined,
32
36
  writeToClipboard: undefined,
33
37
  });
@@ -54,6 +58,66 @@ const copyLabel = computed(() =>
54
58
  : (labels.value.codeEditorCopy ?? 'Copy code'),
55
59
  );
56
60
 
61
+ // Whether there is code hidden above / below the visible area. Each fade is
62
+ // shown only for the direction that can actually scroll, so a document that
63
+ // fits its box shows none.
64
+ const canScrollUp = ref(false);
65
+ const canScrollDown = ref(false);
66
+ let scrollTarget: HTMLElement | undefined;
67
+ let scrollObserver: ResizeObserver | undefined;
68
+
69
+ // 53px is the band height the design file uses for this mask, rather than
70
+ // `UiFadeMask`'s own 4rem/6rem default, which is sized for page-level surfaces.
71
+ const FADE_HEIGHT = '53px';
72
+
73
+ function edgeToFade(up: boolean, down: boolean): 'top' | 'bottom' | 'both' {
74
+ if (up && down) return 'both';
75
+ return up ? 'top' : 'bottom';
76
+ }
77
+
78
+ // `UiFadeMask` takes the edges to draw rather than two independent flags, and
79
+ // has no "neither" position, so a document that fits collapses the band to zero
80
+ // height instead. Swapping the wrapper out for a plain `div` on every change
81
+ // would remount the host element and take CodeMirror's DOM with it, so the mask
82
+ // stays mounted for as long as `scrollFade` is on.
83
+ const fadeProps = computed(() => {
84
+ if (!props.scrollFade) return {};
85
+ const up = canScrollUp.value;
86
+ const down = canScrollDown.value;
87
+ return {
88
+ position: edgeToFade(up, down),
89
+ style: {
90
+ '--ui-fade-mask-height': up || down ? FADE_HEIGHT : '0px',
91
+ '--ck-code-fade-top': `var(--background-surface-${props.fadeTopBackground})`,
92
+ '--ck-code-fade-bottom': `var(--background-surface-${props.fadeBottomBackground})`,
93
+ },
94
+ };
95
+ });
96
+
97
+ function syncScrollFades(): void {
98
+ if (!scrollTarget) return;
99
+ const { scrollTop, scrollHeight, clientHeight } = scrollTarget;
100
+ canScrollUp.value = scrollTop > 1;
101
+ // A pixel of slack: fractional layout sizes otherwise leave the bottom fade
102
+ // showing when the document is scrolled fully down.
103
+ canScrollDown.value = scrollTop + clientHeight < scrollHeight - 1;
104
+ }
105
+
106
+ function watchScrolling(scroller: HTMLElement, content: HTMLElement): void {
107
+ scrollTarget = scroller;
108
+ scroller.addEventListener('scroll', syncScrollFades, { passive: true });
109
+ if (typeof ResizeObserver !== 'undefined') {
110
+ // Neither of these fires a scroll event. The scroller covers the box being
111
+ // resized; the content is watched separately because once `maxHeight` caps
112
+ // the scroller, typing a new line grows only the content, and the bottom
113
+ // fade would otherwise never appear.
114
+ scrollObserver = new ResizeObserver(syncScrollFades);
115
+ scrollObserver.observe(scroller);
116
+ scrollObserver.observe(content);
117
+ }
118
+ syncScrollFades();
119
+ }
120
+
57
121
  let editor: CodeMirrorHandle | undefined;
58
122
  let destroyed = false;
59
123
  // Last value emitted or applied, so an echo from the parent is not re-applied.
@@ -88,7 +152,6 @@ onMounted(async () => {
88
152
  badge: props.badge,
89
153
  onChange: handleChange,
90
154
  onFocus: () => emit('focus'),
91
- onBlur: () => emit('blur'),
92
155
  });
93
156
 
94
157
  // Unmounted while the grammar was loading.
@@ -100,6 +163,7 @@ onMounted(async () => {
100
163
  editor = handle;
101
164
  // Props may have moved on while the grammar loaded; catch up.
102
165
  if (props.modelValue !== initialCode) handle.setCode(props.modelValue);
166
+ watchScrolling(handle.view.scrollDOM, handle.view.contentDOM);
103
167
  isReady.value = true;
104
168
  if (props.autofocus) handle.focus();
105
169
  });
@@ -110,6 +174,7 @@ watch(
110
174
  if (next === lastKnownCode) return;
111
175
  lastKnownCode = next;
112
176
  editor?.setCode(next);
177
+ void nextTick(syncScrollFades);
113
178
  },
114
179
  );
115
180
 
@@ -138,6 +203,9 @@ watch(
138
203
  onBeforeUnmount(() => {
139
204
  destroyed = true;
140
205
  clearTimeout(copyTimer);
206
+ scrollTarget?.removeEventListener('scroll', syncScrollFades);
207
+ scrollObserver?.disconnect();
208
+ scrollTarget = undefined;
141
209
  editor?.destroy();
142
210
  editor = undefined;
143
211
  });
@@ -163,8 +231,9 @@ function isInsideRoot(target: EventTarget | null): boolean {
163
231
  return target instanceof Node && (rootRef.value?.contains(target) ?? false);
164
232
  }
165
233
 
166
- // CodeMirror reports focus on its own content; the header's copy button is
167
- // outside that, so only a move out of the whole component counts as a blur.
234
+ // The single source of `blur`. CodeMirror reports focus loss on its own
235
+ // content, which would fire for a move to the header's copy button; only a move
236
+ // out of the whole component counts, and only the root can see that.
168
237
  function onFocusOut(event: FocusEvent): void {
169
238
  if (!isInsideRoot(event.relatedTarget)) emit('blur');
170
239
  }
@@ -184,7 +253,7 @@ defineExpose({ focus, getCode });
184
253
  <div
185
254
  ref="rootRef"
186
255
  :class="[
187
- 'ck-code-editor ck-code-surface border-border-surface-default min-w-0 overflow-hidden rounded-lg border',
256
+ 'ck-code-editor ck-code-surface border-border-surface-default bg-background-input-default min-w-0 overflow-hidden rounded-xl border',
188
257
  isReady && 'ck-code-editor--ready',
189
258
  isLocked && 'ck-code-editor--readonly',
190
259
  disabled && 'ck-code-editor--disabled',
@@ -210,30 +279,98 @@ defineExpose({ focus, getCode });
210
279
  </UiButton>
211
280
  </div>
212
281
 
213
- <div
214
- ref="hostRef"
215
- class="ck-code-editor__host"
216
- :style="maxHeight ? { '--ck-code-max-height': maxHeight } : undefined"
217
- />
282
+ <component
283
+ :is="scrollFade ? UiFadeMask : 'div'"
284
+ v-bind="fadeProps"
285
+ class="ck-code-editor__body min-w-0"
286
+ >
287
+ <div
288
+ ref="hostRef"
289
+ class="ck-code-editor__host"
290
+ :style="maxHeight ? { '--ck-code-max-height': maxHeight } : undefined"
291
+ />
292
+ </component>
218
293
  </div>
219
294
  </template>
220
295
 
221
296
  <style scoped>
222
- /* The editor fills the panel; `:deep` is needed because CodeMirror builds its
223
- * own DOM, which scoped styles do not reach. */
224
- .ck-code-editor__host :deep(.cm-editor) {
225
- background: var(--ck-cm-bg);
226
- color: var(--ck-cm-foreground);
297
+ /* The body follows the design-system field rather than `UiCodeBlock`: body
298
+ * text in the primary content colour on the input surface, with muted mono
299
+ * line numbers. Only the syntax colours stay shared with `UiCodeBlock` (see
300
+ * `helpers/codemirror/code-palette.css`). The overrides live here, not in
301
+ * `theme.ts`, because `CkMilkdownEditor`'s code blocks use that theme too and
302
+ * must keep matching the rendered block. */
303
+ .ck-code-editor {
304
+ /* `theme.ts` paints the editor from these; transparent lets the root's
305
+ * input surface show through. */
306
+ --ck-cm-bg: transparent;
307
+ --ck-cm-foreground: oklch(var(--content-on-surface-primary));
308
+ --ck-cm-line-number: oklch(var(--content-on-surface-muted));
227
309
  }
228
310
 
311
+ /* `:deep` is needed below because CodeMirror builds its own DOM, which scoped
312
+ * styles do not reach. Same declarations as the `body-14-regular` typography utility. */
229
313
  .ck-code-editor__host :deep(.cm-scroller) {
230
314
  max-height: var(--ck-code-max-height, none);
315
+ font-family: Saans, sans-serif;
316
+ font-size: 0.875rem;
317
+ font-weight: 400;
318
+ line-height: 1.25rem;
231
319
  }
232
320
 
233
- /* Nothing is being edited, so the active-line band would only be noise. */
234
- .ck-code-editor--readonly .ck-code-editor__host :deep(.cm-activeLine),
235
- .ck-code-editor--readonly .ck-code-editor__host :deep(.cm-activeLineGutter) {
236
- background: transparent;
321
+ /* 12px below the header, 16px above the bottom edge, and a 20px right inset,
322
+ * as in the design. */
323
+ .ck-code-editor__host :deep(.cm-content) {
324
+ padding: 0.75rem 1.25rem 1rem 1rem;
325
+ }
326
+
327
+ /* The number column already carries the 1rem left inset, so the code must not
328
+ * pay for it a second time. The sibling selector only matches while the gutter
329
+ * is mounted, which is exactly when `showLineNumbers` is on. */
330
+ .ck-code-editor__host :deep(.cm-gutters ~ .cm-content) {
331
+ padding-left: 0;
332
+ }
333
+
334
+ /* Numbers stay monospaced so the column does not jitter as digits change; the
335
+ * 12px right padding is the gap before the code. */
336
+ .ck-code-editor__host :deep(.cm-lineNumbers .cm-gutterElement) {
337
+ min-width: 0;
338
+ padding: 0 0.75rem 0 1rem;
339
+ /* `CODE_FONT_FAMILY` from `theme.ts`, repeated because importing it here
340
+ * would pull CodeMirror out of its lazy chunk. */
341
+ font-family:
342
+ ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono',
343
+ monospace;
344
+ font-weight: 500;
345
+ }
346
+
347
+ .ck-code-editor__host :deep(.cm-placeholder) {
348
+ color: oklch(var(--content-on-surface-muted));
349
+ }
350
+
351
+ /* Each band fades into its own surface (`fadeTopBackground` /
352
+ * `fadeBottomBackground`), whereas `UiFadeMask`'s `background` sets one for
353
+ * both and only knows `primary` and `secondary`. The gradient, edges and band
354
+ * height stay the mask's own. `[data-background]` outranks the mask's
355
+ * per-surface rule regardless of stylesheet order. */
356
+ .ck-code-editor__body
357
+ :deep(.ui-fade-mask__band[data-background][data-position='top']) {
358
+ --ui-fade-mask-surface: var(--ck-code-fade-top);
359
+ /* The mask turns its top band opaque halfway down on desktop, which left a
360
+ * solid strip under the header; mirror the bottom band's stops instead. */
361
+ --ui-fade-mask-stop: 20%;
362
+ }
363
+
364
+ .ck-code-editor__body
365
+ :deep(.ui-fade-mask__band[data-background][data-position='bottom']) {
366
+ --ui-fade-mask-surface: var(--ck-code-fade-bottom);
367
+ }
368
+
369
+ @media (min-width: 768px) {
370
+ .ck-code-editor__body
371
+ :deep(.ui-fade-mask__band[data-background][data-position='top']) {
372
+ --ui-fade-mask-stop: 100%;
373
+ }
237
374
  }
238
375
 
239
376
  .ck-code-editor--disabled {
@@ -23,8 +23,8 @@ export interface CkCodeEditorBadgeContent {
23
23
  /** Native tooltip, useful for showing the underlying reference. */
24
24
  title?: string;
25
25
  /**
26
- * Classes replacing the default pill styling, which mirrors `UiBadge`'s
27
- * `outline` variant at `small` size.
26
+ * Classes replacing the default pill styling, which is the secondary badge
27
+ * the ChatKit design file specifies.
28
28
  */
29
29
  class?: string;
30
30
  }
@@ -39,8 +39,12 @@ export interface CreateCodeMirrorOptions {
39
39
  badge?: CkCodeEditorBadgeRule;
40
40
  /** Called synchronously whenever the document changes. */
41
41
  onChange: (value: string) => void;
42
+ /**
43
+ * Called when the editing area takes focus. There is deliberately no `onBlur`
44
+ * counterpart: losing focus to the header's copy button is not leaving the
45
+ * editor, and only the component around this view can tell the difference.
46
+ */
42
47
  onFocus: () => void;
43
- onBlur: () => void;
44
48
  }
45
49
 
46
50
  /** Imperative surface `CkCodeEditor` drives; keeps CodeMirror out of the SFC. */
@@ -89,6 +93,22 @@ export async function resolveLanguage(
89
93
  }
90
94
  }
91
95
 
96
+ /**
97
+ * Pick the line ending a document already uses.
98
+ *
99
+ * CodeMirror splits on `\n`, `\r\n` and `\r` alike, but `doc.toString()` always
100
+ * joins with `\n`. Left alone, opening a file with Windows line endings and
101
+ * typing one character would silently rewrite every line ending in it. Telling
102
+ * the editor which separator this document uses keeps it intact, both when
103
+ * reading the value back and when the user presses Enter.
104
+ */
105
+ export function detectLineSeparator(doc: string): string {
106
+ const crlf = (doc.match(/\r\n/g) ?? []).length;
107
+ if (crlf === 0) return '\n';
108
+ const bareLf = (doc.match(/(?<!\r)\n/g) ?? []).length;
109
+ return crlf >= bareLf ? '\r\n' : '\n';
110
+ }
111
+
92
112
  // `readOnly` rejects edits while keeping the text selectable; `editable`
93
113
  // additionally drops `contenteditable`, which hides the caret and keeps the
94
114
  // editor out of the tab order.
@@ -96,6 +116,11 @@ function readonlyState(readonly: boolean): Extension {
96
116
  return [EditorState.readOnly.of(readonly), EditorView.editable.of(!readonly)];
97
117
  }
98
118
 
119
+ /** The document as text, with the line endings it came in with. */
120
+ function readDoc(state: EditorState): string {
121
+ return state.doc.sliceString(0, state.doc.length, state.lineBreak);
122
+ }
123
+
99
124
  function baseExtensions(options: CreateCodeMirrorOptions): Extension[] {
100
125
  return [
101
126
  // Deliberately not CodeMirror's `basicSetup`: it also brings autocomplete,
@@ -116,11 +141,8 @@ function baseExtensions(options: CreateCodeMirrorOptions): Extension[] {
116
141
  ...(options.ariaLabel ? { 'aria-label': options.ariaLabel } : {}),
117
142
  }),
118
143
  EditorView.updateListener.of((update) => {
119
- if (update.docChanged) options.onChange(update.state.doc.toString());
120
- if (update.focusChanged) {
121
- if (update.view.hasFocus) options.onFocus();
122
- else options.onBlur();
123
- }
144
+ if (update.docChanged) options.onChange(readDoc(update.state));
145
+ if (update.focusChanged && update.view.hasFocus) options.onFocus();
124
146
  }),
125
147
  ...(options.placeholder ? [placeholderExt(options.placeholder)] : []),
126
148
  ];
@@ -140,8 +162,11 @@ export async function createCodeMirror(
140
162
  const lineNumbersSlot = new Compartment();
141
163
  const wrappingSlot = new Compartment();
142
164
  const badgeSlot = new Compartment();
165
+ const lineSeparatorSlot = new Compartment();
143
166
 
144
167
  const support = await resolveLanguage(options.language);
168
+ // Guards against out-of-order grammar loads; see `setLanguage` below.
169
+ let languageRequestId = 0;
145
170
 
146
171
  const view = new EditorView({
147
172
  parent: options.parent,
@@ -154,6 +179,9 @@ export async function createCodeMirror(
154
179
  lineNumbersSlot.of(options.showLineNumbers ? lineNumbers() : []),
155
180
  wrappingSlot.of(options.lineWrapping ? EditorView.lineWrapping : []),
156
181
  badgeSlot.of(options.badge ? badgeExtension(options.badge) : []),
182
+ lineSeparatorSlot.of(
183
+ EditorState.lineSeparator.of(detectLineSeparator(options.doc)),
184
+ ),
157
185
  ],
158
186
  }),
159
187
  });
@@ -164,9 +192,14 @@ export async function createCodeMirror(
164
192
 
165
193
  return {
166
194
  view,
167
- getCode: () => view.state.doc.toString(),
195
+ getCode: () => readDoc(view.state),
168
196
  setCode: (value) => {
169
- if (value === view.state.doc.toString()) return;
197
+ if (value === readDoc(view.state)) return;
198
+ // New content may use different line endings from what is loaded.
199
+ const separator = detectLineSeparator(value);
200
+ if (separator !== view.state.lineBreak) {
201
+ reconfigure(lineSeparatorSlot, EditorState.lineSeparator.of(separator));
202
+ }
170
203
  // Replacing the whole document would otherwise send the cursor home
171
204
  // whenever a parent echoes the value back; clamp it to the new length.
172
205
  const cursor = Math.min(view.state.selection.main.head, value.length);
@@ -176,7 +209,11 @@ export async function createCodeMirror(
176
209
  });
177
210
  },
178
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;
179
215
  const next = await resolveLanguage(language);
216
+ if (request !== languageRequestId) return;
180
217
  reconfigure(languageSlot, next ? [next] : []);
181
218
  },
182
219
  setReadonly: (readonly) => {
@@ -6,14 +6,24 @@ export type {
6
6
  CkCodeEditorBadgeRule,
7
7
  } from './badgeDecoration';
8
8
 
9
+ /**
10
+ * Opaque design-system surfaces the scroll fade can dissolve into, each naming
11
+ * a `background-surface-*` token. Translucent surfaces are left out: the fade
12
+ * gradient needs a solid colour to end on.
13
+ */
14
+ export type CkCodeEditorFadeSurface =
15
+ | 'primary'
16
+ | 'secondary'
17
+ | 'floating'
18
+ | 'modal';
19
+
9
20
  /**
10
21
  * Single-document code editor built on [CodeMirror 6](https://codemirror.net/).
11
- * Its surface is the one `UiCodeBlock` renders for fenced code inside
12
- * {@link CkMarkdownRenderer}: the same bordered, rounded panel, the same muted
13
- * header carrying the language and a copy button, the same monospace metrics,
14
- * and syntax colours approximating Shiki's `github-light` / `github-dark`. A
15
- * snippet therefore looks the same whether it is being read in a reply or
16
- * edited here.
22
+ * It shares `UiCodeBlock`'s muted header, carrying the language and a copy
23
+ * button, and its syntax colours approximating Shiki's `github-light` /
24
+ * `github-dark`, so highlighted code keeps its colours between a reply and the
25
+ * editor. The body follows the design-system input field: body text on the
26
+ * input surface, with muted monospace line numbers.
17
27
  *
18
28
  * Use it wherever a whole document *is* code — a snippet field, a config or
19
29
  * template editor, a "create skill" form. For prose that merely contains code,
@@ -102,6 +112,32 @@ export interface CkCodeEditorProps {
102
112
  * `'24rem'`. Unset, the editor grows with its content.
103
113
  */
104
114
  maxHeight?: string;
115
+ /**
116
+ * Fade the code out at the top and bottom edges when there is more of it
117
+ * scrolled out of view, so a clipped document reads as continuing rather than
118
+ * stopping mid-line.
119
+ *
120
+ * Each edge appears only when that direction can actually scroll, so a
121
+ * document that fits shows no fade at all. Relevant mainly alongside
122
+ * `maxHeight`, which is what makes the editor scroll in the first place.
123
+ *
124
+ * @default true
125
+ */
126
+ scrollFade?: boolean;
127
+ /**
128
+ * Surface the top scroll fade dissolves into, as a `background-surface-*`
129
+ * token name. The gradient itself is `UiFadeMask`'s; only its colour changes.
130
+ *
131
+ * @default 'secondary'
132
+ */
133
+ fadeTopBackground?: CkCodeEditorFadeSurface;
134
+ /**
135
+ * Surface the bottom scroll fade dissolves into, as a `background-surface-*`
136
+ * token name. The gradient itself is `UiFadeMask`'s; only its colour changes.
137
+ *
138
+ * @default 'floating'
139
+ */
140
+ fadeBottomBackground?: CkCodeEditorFadeSurface;
105
141
  /**
106
142
  * Move focus into the editor once it has been created.
107
143
  *
@@ -470,3 +470,43 @@ describe('CkMarkdownRenderer', () => {
470
470
  });
471
471
  });
472
472
  });
473
+
474
+ describe('breaks', () => {
475
+ it('collapses a single newline into one line by default', () => {
476
+ const { container } = render(CkMarkdownRenderer, {
477
+ props: { content: 'line one\nline two' },
478
+ });
479
+
480
+ expect(container.querySelectorAll('p')).toHaveLength(1);
481
+ expect(container.querySelector('br')).toBeNull();
482
+ });
483
+
484
+ it('keeps a single newline as a line break when `breaks` is set', () => {
485
+ const { container } = render(CkMarkdownRenderer, {
486
+ props: { content: 'line one\nline two', breaks: true },
487
+ });
488
+
489
+ expect(container.querySelectorAll('p')).toHaveLength(1);
490
+ expect(container.querySelectorAll('br')).toHaveLength(1);
491
+ expect(container.querySelector('p')?.textContent).toContain('line one');
492
+ expect(container.querySelector('p')?.textContent).toContain('line two');
493
+ });
494
+
495
+ it('still starts a new paragraph on a blank line', () => {
496
+ const { container } = render(CkMarkdownRenderer, {
497
+ props: { content: 'one\n\ntwo', breaks: true },
498
+ });
499
+
500
+ expect(container.querySelectorAll('p')).toHaveLength(2);
501
+ });
502
+
503
+ it('leaves newlines inside fenced code alone', () => {
504
+ const { container } = render(CkMarkdownRenderer, {
505
+ props: { content: '```\nfirst\nsecond\n```', breaks: true },
506
+ });
507
+
508
+ const code = container.querySelector('pre code');
509
+ expect(code?.textContent).toBe('first\nsecond\n');
510
+ expect(code?.querySelector('br')).toBeNull();
511
+ });
512
+ });
@@ -139,6 +139,17 @@ const plugins = [uppercasePlugin];
139
139
  <CkMarkdownRenderer :content="content" :plugins="plugins" />
140
140
  </template>`;
141
141
 
142
+ const withLineBreaksTemplateSource = `<script setup lang="ts">
143
+ import { CkMarkdownRenderer } from '@aleph-alpha/chat-kit';
144
+
145
+ // Typed in an editor as three separate lines.
146
+ const content = 'Roses are red\\nViolets are blue\\nMarkdown collapses these';
147
+ </script>
148
+
149
+ <template>
150
+ <CkMarkdownRenderer :content="content" breaks />
151
+ </template>`;
152
+
142
153
  /**
143
154
  * Renders inline Markdown with bold, italic, and inline code. Use as the
144
155
  * default way to display assistant prose replies.
@@ -553,3 +564,23 @@ export const WithUnsupportedAndCodeMarkers: Story = {
553
564
  '<CkMarkdownRenderer :content="content" :citations="citations" />',
554
565
  }),
555
566
  };
567
+
568
+ /**
569
+ * `breaks` keeps single newlines as line breaks. Without it CommonMark treats a
570
+ * lone newline as whitespace, so lines typed separately in an editor run
571
+ * together. A blank line still starts a new paragraph either way, and newlines
572
+ * inside fenced code are never touched.
573
+ */
574
+ export const WithLineBreaks: Story = {
575
+ args: {
576
+ content: [
577
+ 'Roses are red',
578
+ 'Violets are blue',
579
+ 'These three lines were typed separately.',
580
+ '',
581
+ 'This is a new paragraph.',
582
+ ].join('\n'),
583
+ breaks: true,
584
+ },
585
+ parameters: { docs: { source: { code: withLineBreaksTemplateSource } } },
586
+ };
@@ -14,6 +14,7 @@ import {
14
14
  defaultChatKitLabels,
15
15
  useChatKitLabels,
16
16
  } from '../../../handlers/useChatKitLabels';
17
+ import { remarkBreaks } from '../../../helpers/unified/remarkBreaks';
17
18
  import { useCitationRendering } from './useCitationRendering';
18
19
  import type { CkCitationSource } from '../CkCitation';
19
20
  import type { CkMarkdownElementClasses } from './types';
@@ -100,6 +101,7 @@ export default defineComponent({
100
101
  default: () => ({}),
101
102
  },
102
103
  plugins: { type: Array as PropType<PluggableList>, default: undefined },
104
+ breaks: { type: Boolean, default: false },
103
105
  citations: {
104
106
  type: Array as PropType<CkCitationSource[]>,
105
107
  default: undefined,
@@ -123,6 +125,7 @@ export default defineComponent({
123
125
 
124
126
  const effectivePlugins = computed<PluggableList>(() => [
125
127
  ...(props.plugins ?? []),
128
+ ...(props.breaks ? [remarkBreaks] : []),
126
129
  ...citationPlugins.value,
127
130
  ]);
128
131
 
@@ -49,6 +49,19 @@ export interface CkMarkdownRendererProps {
49
49
  * generated HTML elements (e.g. `h1`, `p`, `ul`).
50
50
  */
51
51
  classes?: CkMarkdownElementClasses;
52
+ /**
53
+ * Treat a single newline as a line break, so two lines typed in an editor
54
+ * render as two lines.
55
+ *
56
+ * Off by default, because CommonMark says a lone newline is only whitespace
57
+ * and existing content may rely on that. Turn it on wherever people type the
58
+ * markdown themselves and expect their line breaks to survive; a blank line
59
+ * still starts a new paragraph either way, and newlines inside code spans and
60
+ * fenced blocks are never affected.
61
+ *
62
+ * @default false
63
+ */
64
+ breaks?: boolean;
52
65
  /** Extra [unified](https://unifiedjs.com/) plugins to extend parsing. */
53
66
  plugins?: PluggableList;
54
67
  /**
@@ -412,9 +412,12 @@
412
412
  .milkdown-code-block
413
413
  .cm-lineNumbers
414
414
  .cm-gutterElement {
415
- min-width: 2.25rem;
416
- padding: 0 0.75rem 0 1rem;
417
- color: oklch(var(--content-on-surface-muted));
415
+ /* Geometry and colour come from `theme.ts`, which matches `UiCodeBlock`'s
416
+ * gutter; this rule only exists because Crepe's own code-block styles would
417
+ * otherwise win on specificity. */
418
+ min-width: 4rem;
419
+ padding: 0 1rem;
420
+ color: var(--ck-cm-line-number);
418
421
  text-align: right;
419
422
  }
420
423
 
@@ -90,5 +90,6 @@ export { CkCodeEditor } from './CkCodeEditor';
90
90
  export type {
91
91
  CkCodeEditorEmits,
92
92
  CkCodeEditorExposed,
93
+ CkCodeEditorFadeSurface,
93
94
  CkCodeEditorProps,
94
95
  } from './CkCodeEditor';
@@ -73,6 +73,7 @@ export type {
73
73
  CkMilkdownEditorProps,
74
74
  CkCodeEditorEmits,
75
75
  CkCodeEditorExposed,
76
+ CkCodeEditorFadeSurface,
76
77
  CkCodeEditorProps,
77
78
  } from './base';
78
79
 
@@ -15,11 +15,23 @@
15
15
  * Exact parity is not achievable for every token: Shiki classifies with
16
16
  * TextMate grammars and CodeMirror with Lezer, and the two disagree at the
17
17
  * edges. The mapping in `theme.ts` targets the classes that actually occur in
18
- * everyday code.
18
+ * everyday code. Where one Lezer tag covers cases the GitHub themes colour
19
+ * differently, the mapping follows the more common one, which leaves a few
20
+ * known gaps: YAML keys and plain scalars both arrive as tags that mean
21
+ * something else in JavaScript, so they stay unhighlighted; `typeName` is blue
22
+ * here because that is right for TypeScript's primitives, though Rust and Go
23
+ * would want purple and red; and a fenced code block inside a Markdown
24
+ * document is not highlighted in its own language the way Shiki's embedded
25
+ * grammars do it.
19
26
  */
20
27
 
21
28
  .ck-code-surface {
22
- --ck-cm-bg: #ffffff;
29
+ /* Held as bare oklch components, not a hex, because that is the form both
30
+ * `oklch()` and `UiFadeMask`'s surface variable need in order to fade the
31
+ * colour out to zero alpha. `--ck-cm-bg` is derived from it so there is one
32
+ * source of truth. */
33
+ --ck-cm-bg-components: 100% 0 89.88;
34
+ --ck-cm-bg: oklch(var(--ck-cm-bg-components));
23
35
  --ck-cm-foreground: #24292e;
24
36
  --ck-cm-keyword: #d73a49;
25
37
  --ck-cm-string: #032f62;
@@ -34,10 +46,18 @@
34
46
  --ck-cm-tag: #22863a;
35
47
  --ck-cm-error: #b31d28;
36
48
  --ck-cm-selection: oklch(var(--background-accent-soft));
49
+ /* Line numbers. `UiCodeBlock` draws its gutter as the code foreground at
50
+ * 40% opacity rather than as a colour of its own, so this is derived from
51
+ * `--ck-cm-foreground` and needs no dark-mode counterpart. */
52
+ --ck-cm-line-number: color-mix(
53
+ in oklab,
54
+ var(--ck-cm-foreground) 40%,
55
+ transparent
56
+ );
37
57
  }
38
58
 
39
59
  html.dark .ck-code-surface {
40
- --ck-cm-bg: #24292e;
60
+ --ck-cm-bg-components: 27.81% 0.0117 248.25;
41
61
  --ck-cm-foreground: #e1e4e8;
42
62
  --ck-cm-keyword: #f97583;
43
63
  --ck-cm-string: #9ecbff;