@aleph-alpha/chat-kit 6.4.0 → 6.4.1

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 (71) hide show
  1. package/dist/{CkHistory.vue_vue_type_script_setup_true_lang-B0WUzTHa.js → CkHistory.vue_vue_type_script_setup_true_lang-BvRIp3m6.js} +71 -14
  2. package/dist/adapters/responses-api.js +1 -1
  3. package/dist/chat-kit.css +81 -11
  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 +1 -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 +12 -0
  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/index.js +1 -1
  21. package/dist/composables/index.js +1 -1
  22. package/dist/{createCodeMirror-5iswYetS.js → createCodeMirror-BO-TWTzn.js} +27 -9
  23. package/dist/{createCrepeEditor-C-AQ1w_e.js → createCrepeEditor-BGSlwmKn.js} +4 -4
  24. package/dist/handlers/index.js +2 -2
  25. package/dist/helpers/codemirror/theme.d.ts.map +1 -1
  26. package/dist/helpers/unified/remarkBreaks.d.ts +15 -0
  27. package/dist/helpers/unified/remarkBreaks.d.ts.map +1 -0
  28. package/dist/{index-DLjsqQHx.js → index--Yonjzfi.js} +3 -3
  29. package/dist/{index-Mh3WUU-6.js → index-2YPxXIaf.js} +1 -1
  30. package/dist/{index-CWp_t8Wl.js → index-B4uqe-fL.js} +2 -2
  31. package/dist/{index-hEJ_Bavx.js → index-Bfo4U-Al.js} +2 -2
  32. package/dist/{index-g2X42srv.js → index-Bw39pbRL.js} +3 -3
  33. package/dist/{index-CIF4qQzR.js → index-CGEgwupi.js} +4 -4
  34. package/dist/{index-Dbi75pKH.js → index-CZmdzLaU.js} +3 -3
  35. package/dist/{index-CR2kGrpY.js → index-CeaT_FBl.js} +3 -3
  36. package/dist/{index-DfLCrnVq.js → index-CefLTKmi.js} +2 -2
  37. package/dist/{index-CLdXUpL8.js → index-CfVHzirA.js} +5 -5
  38. package/dist/{index-D0IZW-Up.js → index-CrBanggA.js} +2 -2
  39. package/dist/{index-BlIc9r0r.js → index-Cx5ThXzs.js} +3 -3
  40. package/dist/{index-CZFx44C7.js → index-D0DQjdsq.js} +2 -2
  41. package/dist/{index-DPgw0Cf_.js → index-DH9e_hqJ.js} +2 -2
  42. package/dist/{index-hbS5PDHj.js → index-DTomeh-p.js} +3 -3
  43. package/dist/{index-GpSpJ3ti.js → index-DZGfsazg.js} +3 -3
  44. package/dist/{index-QAs789O5.js → index-DlLnTKv3.js} +2 -2
  45. package/dist/{index-ABUMl7H3.js → index-DqUiBDRo.js} +3 -3
  46. package/dist/{index-CFGO7LRz.js → index-GDUM7K5V.js} +2 -2
  47. package/dist/{index-Db5-PJG-.js → index-Hro6EPZS.js} +2 -2
  48. package/dist/{index-CG8e3146.js → index-KgumaL1c.js} +1 -1
  49. package/dist/{index-XZeturb3.js → index-TemHfU1M.js} +3 -3
  50. package/dist/{index-DKwEe_Tu.js → index-g_Bs6dNr.js} +4 -4
  51. package/dist/{index-S4ylc8JK.js → index-oO_X5wU0.js} +3 -3
  52. package/dist/{index-BqWO6mvz.js → index-ptbBQ53d.js} +4 -4
  53. package/dist/index.js +5 -5
  54. package/dist/{markdownToPlainText-BOtlQ_BY.js → markdownToPlainText-DN_G-I6g.js} +1 -1
  55. package/dist/{useChatKitLabels-CatQSpfL.js → useChatKitLabels-DxW0N5U-.js} +1 -1
  56. package/dist/{useMessageHandler-nnsort0-.js → useMessageHandler-Bkzrdgfj.js} +1 -1
  57. package/package.json +1 -1
  58. package/src/components/base/CkCodeEditor/CkCodeEditor.spec.ts +100 -1
  59. package/src/components/base/CkCodeEditor/CkCodeEditor.stories.ts +3 -1
  60. package/src/components/base/CkCodeEditor/CkCodeEditor.vue +127 -10
  61. package/src/components/base/CkCodeEditor/badgeDecoration.ts +2 -2
  62. package/src/components/base/CkCodeEditor/createCodeMirror.ts +45 -8
  63. package/src/components/base/CkCodeEditor/types.ts +12 -0
  64. package/src/components/base/CkMarkdownRenderer/CkMarkdownRenderer.spec.ts +40 -0
  65. package/src/components/base/CkMarkdownRenderer/CkMarkdownRenderer.stories.ts +31 -0
  66. package/src/components/base/CkMarkdownRenderer/CkMarkdownRenderer.vue +3 -0
  67. package/src/components/base/CkMarkdownRenderer/types.ts +13 -0
  68. package/src/components/base/CkMilkdownEditor/milkdown-theme.css +6 -3
  69. package/src/helpers/codemirror/code-palette.css +23 -3
  70. package/src/helpers/codemirror/theme.ts +44 -8
  71. package/src/helpers/unified/remarkBreaks.ts +32 -0
@@ -1,7 +1,7 @@
1
- import { L as LRLanguage, a as LanguageSupport, p as parseMixed, s as styleTags, t as tags } from "./code-palette-DH_G7mZ5.js";
2
- import { html } from "./index-BqWO6mvz.js";
3
- import { javascriptLanguage } from "./index-BlIc9r0r.js";
4
- import { L as LRParser, E as ExternalTokenizer } from "./index-Mh3WUU-6.js";
1
+ import { L as LRLanguage, a as LanguageSupport, p as parseMixed, s as styleTags, t as tags } from "./code-palette-BWrZDi47.js";
2
+ import { html } from "./index-ptbBQ53d.js";
3
+ import { javascriptLanguage } from "./index-Cx5ThXzs.js";
4
+ import { L as LRParser, E as ExternalTokenizer } from "./index-2YPxXIaf.js";
5
5
  const Text = 1, attributeContentSingle = 33, attributeContentDouble = 34, scriptAttributeContentSingle = 35, scriptAttributeContentDouble = 36;
6
6
  const text = /* @__PURE__ */ new ExternalTokenizer((input) => {
7
7
  let start = input.pos;
@@ -1,6 +1,6 @@
1
- import { E as EditorView, d as EditorSelection, a as LanguageSupport, p as parseMixed, b as syntaxTree, L as LRLanguage, s as styleTags, t as tags, i as indentNodeProp, e as delimitedIndent, f as foldNodeProp } from "./code-palette-DH_G7mZ5.js";
2
- import { html } from "./index-BqWO6mvz.js";
3
- import { L as LRParser, E as ExternalTokenizer } from "./index-Mh3WUU-6.js";
1
+ import { E as EditorView, d as EditorSelection, a as LanguageSupport, p as parseMixed, b as syntaxTree, L as LRLanguage, s as styleTags, t as tags, i as indentNodeProp, e as delimitedIndent, f as foldNodeProp } from "./code-palette-BWrZDi47.js";
2
+ import { html } from "./index-ptbBQ53d.js";
3
+ import { L as LRParser, E as ExternalTokenizer } from "./index-2YPxXIaf.js";
4
4
  const interpolationStart = 1, tagStart = 2, endTagStart = 3, text = 180, endrawTagStart = 4, rawText = 181, endcommentTagStart = 5, commentText = 182, InlineComment = 6;
5
5
  function wordChar(code) {
6
6
  return code >= 65 && code <= 90 || code >= 97 && code <= 122;
@@ -1,7 +1,7 @@
1
- import { L as LRParser, E as ExternalTokenizer, C as ContextTracker } from "./index-Mh3WUU-6.js";
2
- import { p as parseMixed, s as styleTags, t as tags, E as EditorView, b as syntaxTree, d as EditorSelection, a as LanguageSupport, L as LRLanguage, i as indentNodeProp, f as foldNodeProp, ag as bracketMatchingHandle } from "./code-palette-DH_G7mZ5.js";
3
- import { cssLanguage, css } from "./index-DfLCrnVq.js";
4
- import { typescriptLanguage, jsxLanguage, tsxLanguage, javascriptLanguage, javascript } from "./index-BlIc9r0r.js";
1
+ import { L as LRParser, E as ExternalTokenizer, C as ContextTracker } from "./index-2YPxXIaf.js";
2
+ import { p as parseMixed, s as styleTags, t as tags, E as EditorView, b as syntaxTree, d as EditorSelection, a as LanguageSupport, L as LRLanguage, i as indentNodeProp, f as foldNodeProp, ag as bracketMatchingHandle } from "./code-palette-BWrZDi47.js";
3
+ import { cssLanguage, css } from "./index-CefLTKmi.js";
4
+ import { typescriptLanguage, jsxLanguage, tsxLanguage, javascriptLanguage, javascript } from "./index-Cx5ThXzs.js";
5
5
  const scriptText = 55, StartCloseScriptTag = 1, styleText = 56, StartCloseStyleTag = 2, textareaText = 57, StartCloseTextareaTag = 3, EndTag = 4, SelfClosingEndTag = 5, StartTag = 6, StartScriptTag = 7, StartStyleTag = 8, StartTextareaTag = 9, StartSelfClosingTag = 10, StartCloseTag = 11, NoMatchStartCloseTag = 12, MismatchedStartCloseTag = 13, missingCloseTag = 58, IncompleteTag = 14, IncompleteCloseTag = 15, commentContent$1 = 59, Element = 21, TagName = 23, Attribute = 24, AttributeName = 25, AttributeValue = 27, UnquotedAttributeValue = 28, ScriptText = 29, StyleText = 32, TextareaText = 35, OpenTag = 37, CloseTag = 38, Dialect_noMatch = 0, Dialect_selfClosing = 1;
6
6
  const selfClosers$1 = {
7
7
  area: true,
package/dist/index.js CHANGED
@@ -1,8 +1,8 @@
1
- import { u } from "./useMessageHandler-nnsort0-.js";
2
- import { C, d, p, u as u2, b } from "./useChatKitLabels-CatQSpfL.js";
3
- import { S, c, d as d2, f, u as u3, a, b as b2, e } from "./index-CZFx44C7.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-B0WUzTHa.js";
5
- import { m as m2 } from "./markdownToPlainText-BOtlQ_BY.js";
1
+ import { u } from "./useMessageHandler-Bkzrdgfj.js";
2
+ import { C, d, p, u as u2, b } from "./useChatKitLabels-DxW0N5U-.js";
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";
5
+ import { m as m2 } from "./markdownToPlainText-DN_G-I6g.js";
6
6
  export {
7
7
  C as CHAT_KIT_LABELS,
8
8
  _ as CkAgentMessage,
@@ -1,4 +1,4 @@
1
- import { u as unified, r as remarkParse, a as remarkGfm } from "./index-CLdXUpL8.js";
1
+ import { u as unified, r as remarkParse, a as remarkGfm } from "./index-CfVHzirA.js";
2
2
  function generateId() {
3
3
  return typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : String(Date.now());
4
4
  }
@@ -1,6 +1,6 @@
1
1
  import { computed, toValue, inject, provide } from "vue";
2
2
  import { p as purify } from "./katex.min-md74IFlY.js";
3
- import { b as asciiAlphanumeric, o as ok, l as longestStreak, f as factorySpace, m as markdownLineEnding, v as visit, c as convert, d as visitParents, S as SKIP, e as ccount, u as unified, r as remarkParse, a as remarkGfm, g as findAndReplace } from "./index-CLdXUpL8.js";
3
+ import { b as asciiAlphanumeric, o as ok, l as longestStreak, c as factorySpace, m as markdownLineEnding, v as visit, d as convert, e as visitParents, S as SKIP, g as ccount, u as unified, r as remarkParse, a as remarkGfm, f as findAndReplace } from "./index-CfVHzirA.js";
4
4
  function normalizeUri(value) {
5
5
  const result = [];
6
6
  let index = -1;
@@ -1,4 +1,4 @@
1
- import { g as generateId } from "./markdownToPlainText-BOtlQ_BY.js";
1
+ import { g as generateId } from "./markdownToPlainText-DN_G-I6g.js";
2
2
  import { reactive, ref, computed, readonly } from "vue";
3
3
  function createEmptyTree() {
4
4
  return { nodes: {}, rootId: null };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aleph-alpha/chat-kit",
3
- "version": "6.4.0",
3
+ "version": "6.4.1",
4
4
  "description": "Chat kit package for building chat interfaces with Vue",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -1,7 +1,7 @@
1
1
  import { render, screen, waitFor } from '@testing-library/vue';
2
2
  import userEvent from '@testing-library/user-event';
3
3
  import CkCodeEditor from './CkCodeEditor.vue';
4
- import { resolveLanguage } from './createCodeMirror';
4
+ import { detectLineSeparator, resolveLanguage } from './createCodeMirror';
5
5
  import { matchBadgeRanges } from './badgeDecoration';
6
6
 
7
7
  // CodeMirror measures its own layout on construction; jsdom has none of the
@@ -233,6 +233,91 @@ describe('CkCodeEditor', () => {
233
233
  expect(pills).toEqual(['alpha']);
234
234
  });
235
235
 
236
+ it('keeps Windows line endings instead of rewriting them', async () => {
237
+ const crlf = 'alpha\r\nbeta\r\ngamma';
238
+ const { emitted } = await renderEditor({ modelValue: crlf });
239
+
240
+ const content = document.querySelector('.cm-content') as HTMLElement;
241
+ await userEvent.click(content);
242
+ await userEvent.type(content, '!');
243
+
244
+ await waitFor(() => expect(emitted()['update:modelValue']).toBeTruthy());
245
+ const updates = emitted()['update:modelValue'] as string[][];
246
+ const value = updates.at(-1)?.[0] ?? '';
247
+ // Every original separator survives, rather than being flattened to \n.
248
+ expect(value).toContain('alpha\r\nbeta\r\ngamma');
249
+ expect(value).not.toContain('alpha\nbeta');
250
+ });
251
+
252
+ it('leaves a Unix document on Unix endings', async () => {
253
+ const { emitted } = await renderEditor({ modelValue: 'alpha\nbeta' });
254
+
255
+ const content = document.querySelector('.cm-content') as HTMLElement;
256
+ await userEvent.click(content);
257
+ await userEvent.type(content, '!');
258
+
259
+ await waitFor(() => expect(emitted()['update:modelValue']).toBeTruthy());
260
+ const updates = emitted()['update:modelValue'] as string[][];
261
+ expect(updates.at(-1)?.[0]).not.toContain('\r');
262
+ });
263
+
264
+ it('wraps the code in the design system fade mask', async () => {
265
+ const { container } = await renderEditor({ modelValue: 'a\nb' });
266
+
267
+ const mask = container.querySelector('.ui-fade-mask');
268
+ expect(mask).toBeInTheDocument();
269
+ expect(mask?.querySelector('.ck-code-editor__host')).toBeInTheDocument();
270
+ // jsdom reports no layout, so nothing scrolls and the bands collapse.
271
+ expect(
272
+ (mask as HTMLElement).style.getPropertyValue('--ui-fade-mask-height'),
273
+ ).toBe('0px');
274
+ });
275
+
276
+ it('omits the fade mask when `scrollFade` is off', async () => {
277
+ const { container } = await renderEditor({
278
+ modelValue: 'a\nb',
279
+ scrollFade: false,
280
+ });
281
+
282
+ expect(container.querySelector('.ui-fade-mask')).toBeNull();
283
+ expect(
284
+ container.querySelector('.ck-code-editor__host'),
285
+ ).toBeInTheDocument();
286
+ });
287
+
288
+ // CodeMirror reports focus changes from a 10ms timeout, so a test asserting
289
+ // that no event followed has to outlast it.
290
+ const settleFocus = () => new Promise((resolve) => setTimeout(resolve, 30));
291
+
292
+ it('keeps focus while it moves between the code and the header', async () => {
293
+ const { emitted } = await renderEditor({ modelValue: 'x' });
294
+
295
+ const content = document.querySelector('.cm-content') as HTMLElement;
296
+ await userEvent.click(content);
297
+ await waitFor(() => expect(emitted().focus).toBeTruthy());
298
+
299
+ // The copy button is inside the component, so the editor has not been left.
300
+ await userEvent.click(screen.getByRole('button', { name: 'Copy code' }));
301
+ await settleFocus();
302
+ expect(emitted().blur).toBeUndefined();
303
+ });
304
+
305
+ it('emits `blur` once focus leaves the component entirely', async () => {
306
+ const outside = document.createElement('button');
307
+ document.body.append(outside);
308
+ const { emitted } = await renderEditor({ modelValue: 'x' });
309
+
310
+ const content = document.querySelector('.cm-content') as HTMLElement;
311
+ await userEvent.click(content);
312
+ await userEvent.click(outside);
313
+
314
+ await waitFor(() => expect(emitted().blur).toHaveLength(1));
315
+ // And exactly once, not again from CodeMirror's own focus tracking.
316
+ await settleFocus();
317
+ expect(emitted().blur).toHaveLength(1);
318
+ outside.remove();
319
+ });
320
+
236
321
  it('does not throw when unmounted before or after the editor is created', async () => {
237
322
  const early = render(CkCodeEditor, { props: { modelValue: 'x' } });
238
323
  expect(() => early.unmount()).not.toThrow();
@@ -282,3 +367,17 @@ describe('matchBadgeRanges', () => {
282
367
  ).toHaveLength(0);
283
368
  });
284
369
  });
370
+
371
+ describe('detectLineSeparator', () => {
372
+ it('reports the separator a document already uses', () => {
373
+ expect(detectLineSeparator('a\nb')).toBe('\n');
374
+ expect(detectLineSeparator('a\r\nb')).toBe('\r\n');
375
+ expect(detectLineSeparator('no newlines at all')).toBe('\n');
376
+ expect(detectLineSeparator('')).toBe('\n');
377
+ });
378
+
379
+ it('follows the majority in a mixed document', () => {
380
+ expect(detectLineSeparator('a\r\nb\r\nc\nd')).toBe('\r\n');
381
+ expect(detectLineSeparator('a\nb\nc\r\nd')).toBe('\n');
382
+ });
383
+ });
@@ -57,6 +57,7 @@ const meta: Meta<typeof CkCodeEditor> = {
57
57
  showLineNumbers: { control: 'boolean' },
58
58
  lineWrapping: { control: 'boolean' },
59
59
  autofocus: { control: 'boolean' },
60
+ scrollFade: { control: 'boolean' },
60
61
  // A regex plus a callback cannot be typed into a control, so the rule is
61
62
  // offered as named presets instead. Without this, badges could only be seen
62
63
  // on the one story that hard-codes a rule.
@@ -324,7 +325,8 @@ export const WithoutHeader: Story = {
324
325
 
325
326
  /**
326
327
  * `maxHeight` caps the scrollable area and `lineWrapping` folds long lines
327
- * instead of scrolling sideways.
328
+ * instead of scrolling sideways. Scroll it: the code fades at whichever edge
329
+ * has more of the document beyond it, and neither edge fades once you reach it.
328
330
  */
329
331
  export const ScrollingAndWrapping: Story = {
330
332
  render: (args) => ({
@@ -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,7 @@ const props = withDefaults(defineProps<CkCodeEditorProps>(), {
28
29
  lineWrapping: false,
29
30
  maxHeight: undefined,
30
31
  autofocus: false,
32
+ scrollFade: true,
31
33
  badge: undefined,
32
34
  writeToClipboard: undefined,
33
35
  });
@@ -54,6 +56,63 @@ const copyLabel = computed(() =>
54
56
  : (labels.value.codeEditorCopy ?? 'Copy code'),
55
57
  );
56
58
 
59
+ // Whether there is code hidden above / below the visible area. Each fade is
60
+ // shown only for the direction that can actually scroll, so a document that
61
+ // fits its box shows none.
62
+ const canScrollUp = ref(false);
63
+ const canScrollDown = ref(false);
64
+ let scrollTarget: HTMLElement | undefined;
65
+ let scrollObserver: ResizeObserver | undefined;
66
+
67
+ // 53px is the band height the design file uses for this mask, rather than
68
+ // `UiFadeMask`'s own 4rem/6rem default, which is sized for page-level surfaces.
69
+ const FADE_HEIGHT = '53px';
70
+
71
+ function edgeToFade(up: boolean, down: boolean): 'top' | 'bottom' | 'both' {
72
+ if (up && down) return 'both';
73
+ return up ? 'top' : 'bottom';
74
+ }
75
+
76
+ // `UiFadeMask` takes the edges to draw rather than two independent flags, and
77
+ // has no "neither" position, so a document that fits collapses the band to zero
78
+ // height instead. Swapping the wrapper out for a plain `div` on every change
79
+ // would remount the host element and take CodeMirror's DOM with it, so the mask
80
+ // stays mounted for as long as `scrollFade` is on.
81
+ const fadeProps = computed(() => {
82
+ if (!props.scrollFade) return {};
83
+ const up = canScrollUp.value;
84
+ const down = canScrollDown.value;
85
+ return {
86
+ position: edgeToFade(up, down),
87
+ background: 'secondary',
88
+ style: { '--ui-fade-mask-height': up || down ? FADE_HEIGHT : '0px' },
89
+ };
90
+ });
91
+
92
+ function syncScrollFades(): void {
93
+ if (!scrollTarget) return;
94
+ const { scrollTop, scrollHeight, clientHeight } = scrollTarget;
95
+ canScrollUp.value = scrollTop > 1;
96
+ // A pixel of slack: fractional layout sizes otherwise leave the bottom fade
97
+ // showing when the document is scrolled fully down.
98
+ canScrollDown.value = scrollTop + clientHeight < scrollHeight - 1;
99
+ }
100
+
101
+ function watchScrolling(scroller: HTMLElement, content: HTMLElement): void {
102
+ scrollTarget = scroller;
103
+ scroller.addEventListener('scroll', syncScrollFades, { passive: true });
104
+ if (typeof ResizeObserver !== 'undefined') {
105
+ // Neither of these fires a scroll event. The scroller covers the box being
106
+ // resized; the content is watched separately because once `maxHeight` caps
107
+ // the scroller, typing a new line grows only the content, and the bottom
108
+ // fade would otherwise never appear.
109
+ scrollObserver = new ResizeObserver(syncScrollFades);
110
+ scrollObserver.observe(scroller);
111
+ scrollObserver.observe(content);
112
+ }
113
+ syncScrollFades();
114
+ }
115
+
57
116
  let editor: CodeMirrorHandle | undefined;
58
117
  let destroyed = false;
59
118
  // Last value emitted or applied, so an echo from the parent is not re-applied.
@@ -88,7 +147,6 @@ onMounted(async () => {
88
147
  badge: props.badge,
89
148
  onChange: handleChange,
90
149
  onFocus: () => emit('focus'),
91
- onBlur: () => emit('blur'),
92
150
  });
93
151
 
94
152
  // Unmounted while the grammar was loading.
@@ -100,6 +158,7 @@ onMounted(async () => {
100
158
  editor = handle;
101
159
  // Props may have moved on while the grammar loaded; catch up.
102
160
  if (props.modelValue !== initialCode) handle.setCode(props.modelValue);
161
+ watchScrolling(handle.view.scrollDOM, handle.view.contentDOM);
103
162
  isReady.value = true;
104
163
  if (props.autofocus) handle.focus();
105
164
  });
@@ -110,6 +169,7 @@ watch(
110
169
  if (next === lastKnownCode) return;
111
170
  lastKnownCode = next;
112
171
  editor?.setCode(next);
172
+ void nextTick(syncScrollFades);
113
173
  },
114
174
  );
115
175
 
@@ -138,6 +198,9 @@ watch(
138
198
  onBeforeUnmount(() => {
139
199
  destroyed = true;
140
200
  clearTimeout(copyTimer);
201
+ scrollTarget?.removeEventListener('scroll', syncScrollFades);
202
+ scrollObserver?.disconnect();
203
+ scrollTarget = undefined;
141
204
  editor?.destroy();
142
205
  editor = undefined;
143
206
  });
@@ -163,8 +226,9 @@ function isInsideRoot(target: EventTarget | null): boolean {
163
226
  return target instanceof Node && (rootRef.value?.contains(target) ?? false);
164
227
  }
165
228
 
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.
229
+ // The single source of `blur`. CodeMirror reports focus loss on its own
230
+ // content, which would fire for a move to the header's copy button; only a move
231
+ // out of the whole component counts, and only the root can see that.
168
232
  function onFocusOut(event: FocusEvent): void {
169
233
  if (!isInsideRoot(event.relatedTarget)) emit('blur');
170
234
  }
@@ -184,7 +248,7 @@ defineExpose({ focus, getCode });
184
248
  <div
185
249
  ref="rootRef"
186
250
  :class="[
187
- 'ck-code-editor ck-code-surface border-border-surface-default min-w-0 overflow-hidden rounded-lg border',
251
+ 'ck-code-editor ck-code-surface border-border-surface-default bg-background-input-default min-w-0 overflow-hidden rounded-xl border',
188
252
  isReady && 'ck-code-editor--ready',
189
253
  isLocked && 'ck-code-editor--readonly',
190
254
  disabled && 'ck-code-editor--disabled',
@@ -210,15 +274,33 @@ defineExpose({ focus, getCode });
210
274
  </UiButton>
211
275
  </div>
212
276
 
213
- <div
214
- ref="hostRef"
215
- class="ck-code-editor__host"
216
- :style="maxHeight ? { '--ck-code-max-height': maxHeight } : undefined"
217
- />
277
+ <component
278
+ :is="scrollFade ? UiFadeMask : 'div'"
279
+ v-bind="fadeProps"
280
+ class="ck-code-editor__body min-w-0"
281
+ >
282
+ <div
283
+ ref="hostRef"
284
+ class="ck-code-editor__host"
285
+ :style="maxHeight ? { '--ck-code-max-height': maxHeight } : undefined"
286
+ />
287
+ </component>
218
288
  </div>
219
289
  </template>
220
290
 
221
291
  <style scoped>
292
+ /* The body follows the design-system field rather than `UiCodeBlock`: body
293
+ * text in the primary content colour on the input surface, with muted mono
294
+ * line numbers. Only the syntax colours stay shared with `UiCodeBlock` (see
295
+ * `helpers/codemirror/code-palette.css`). The overrides live here, not in
296
+ * `theme.ts`, because `CkMilkdownEditor`'s code blocks use that theme too and
297
+ * must keep matching the rendered block. */
298
+ .ck-code-editor {
299
+ --ck-cm-bg: transparent;
300
+ --ck-cm-foreground: oklch(var(--content-on-surface-primary));
301
+ --ck-cm-line-number: oklch(var(--content-on-surface-muted));
302
+ }
303
+
222
304
  /* The editor fills the panel; `:deep` is needed because CodeMirror builds its
223
305
  * own DOM, which scoped styles do not reach. */
224
306
  .ck-code-editor__host :deep(.cm-editor) {
@@ -226,8 +308,43 @@ defineExpose({ focus, getCode });
226
308
  color: var(--ck-cm-foreground);
227
309
  }
228
310
 
311
+ /* Same declarations as the `body-14-regular` typography utility. */
229
312
  .ck-code-editor__host :deep(.cm-scroller) {
230
313
  max-height: var(--ck-code-max-height, none);
314
+ font-family: Saans, sans-serif;
315
+ font-size: 0.875rem;
316
+ font-weight: 400;
317
+ line-height: 1.25rem;
318
+ }
319
+
320
+ /* 12px below the header, 16px above the bottom edge, and a 20px right inset,
321
+ * as in the design. */
322
+ .ck-code-editor__host :deep(.cm-content) {
323
+ padding: 0.75rem 1.25rem 1rem 1rem;
324
+ }
325
+
326
+ /* The number column already carries the 1rem left inset, so the code must not
327
+ * pay for it a second time. The sibling selector only matches while the gutter
328
+ * is mounted, which is exactly when `showLineNumbers` is on. */
329
+ .ck-code-editor__host :deep(.cm-gutters ~ .cm-content) {
330
+ padding-left: 0;
331
+ }
332
+
333
+ /* Numbers stay monospaced so the column does not jitter as digits change; the
334
+ * 12px right padding is the gap before the code. */
335
+ .ck-code-editor__host :deep(.cm-lineNumbers .cm-gutterElement) {
336
+ min-width: 0;
337
+ padding: 0 0.75rem 0 1rem;
338
+ /* `CODE_FONT_FAMILY` from `theme.ts`, repeated because importing it here
339
+ * would pull CodeMirror out of its lazy chunk. */
340
+ font-family:
341
+ ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono',
342
+ monospace;
343
+ font-weight: 500;
344
+ }
345
+
346
+ .ck-code-editor__host :deep(.cm-placeholder) {
347
+ color: oklch(var(--content-on-surface-muted));
231
348
  }
232
349
 
233
350
  /* Nothing is being edited, so the active-line band would only be noise. */
@@ -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) => {
@@ -102,6 +102,18 @@ export interface CkCodeEditorProps {
102
102
  * `'24rem'`. Unset, the editor grows with its content.
103
103
  */
104
104
  maxHeight?: string;
105
+ /**
106
+ * Fade the code out at the top and bottom edges when there is more of it
107
+ * scrolled out of view, so a clipped document reads as continuing rather than
108
+ * stopping mid-line.
109
+ *
110
+ * Each edge appears only when that direction can actually scroll, so a
111
+ * document that fits shows no fade at all. Relevant mainly alongside
112
+ * `maxHeight`, which is what makes the editor scroll in the first place.
113
+ *
114
+ * @default true
115
+ */
116
+ scrollFade?: boolean;
105
117
  /**
106
118
  * Move focus into the editor once it has been created.
107
119
  *
@@ -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
+ });